6ab32e0aa9
Messages larger than a certain size are preambled with a 'nextmessage' message that hold the size of the subsequent message. This is a workaround to a limitation the Poco WebSocket API where if the buffer size is smaller than the received frame the socket ends up in a bad state and must be closed. Unfortunately the new API that avoids this workaround is not yet released by Poco. Here we minimize the need for 'nextmessage' to truely large messages. The limit is now raised from above 1KB to over 63KB. We may raise this limit further, but that will cost each socket that much dedicated buffer size. Change-Id: I01e4c68cdbe67e413c04a9725152224a87ab8267 Reviewed-on: https://gerrit.libreoffice.org/31286 Reviewed-by: Ashod Nakashian <ashnakash@gmail.com> Tested-by: Ashod Nakashian <ashnakash@gmail.com>
566 lines
18 KiB
C++
566 lines
18 KiB
C++
/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4; fill-column: 100 -*- */
|
|
/*
|
|
* This file is part of the LibreOffice project.
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
*/
|
|
|
|
#include "config.h"
|
|
|
|
#include <algorithm>
|
|
#include <condition_variable>
|
|
#include <cstdlib>
|
|
#include <mutex>
|
|
#include <regex>
|
|
#include <thread>
|
|
|
|
#include <Poco/BinaryReader.h>
|
|
#include <Poco/DirectoryIterator.h>
|
|
#include <Poco/Dynamic/Var.h>
|
|
#include <Poco/FileStream.h>
|
|
#include <Poco/JSON/JSON.h>
|
|
#include <Poco/JSON/Parser.h>
|
|
#include <Poco/Net/AcceptCertificateHandler.h>
|
|
#include <Poco/Net/HTTPClientSession.h>
|
|
#include <Poco/Net/HTTPRequest.h>
|
|
#include <Poco/Net/HTTPResponse.h>
|
|
#include <Poco/Net/HTTPSClientSession.h>
|
|
#include <Poco/Net/InvalidCertificateHandler.h>
|
|
#include <Poco/Net/NetException.h>
|
|
#include <Poco/Net/PrivateKeyPassphraseHandler.h>
|
|
#include <Poco/Net/SSLManager.h>
|
|
#include <Poco/Net/Socket.h>
|
|
#include <Poco/Path.h>
|
|
#include <Poco/StreamCopier.h>
|
|
#include <Poco/StringTokenizer.h>
|
|
#include <Poco/Thread.h>
|
|
#include <Poco/URI.h>
|
|
#include <cppunit/extensions/HelperMacros.h>
|
|
|
|
#include <Common.hpp>
|
|
#include "common/FileUtil.hpp"
|
|
#include <Protocol.hpp>
|
|
#include <LOOLWebSocket.hpp>
|
|
#include <UserMessages.hpp>
|
|
#include <Util.hpp>
|
|
|
|
#ifndef TDOC
|
|
#error TDOC must be defined (see Makefile.am)
|
|
#endif
|
|
|
|
/// Common helper testing functions.
|
|
/// Avoid the temptation to reuse from LOOL code!
|
|
/// These are supposed to be testing the latter.
|
|
namespace helpers
|
|
{
|
|
inline
|
|
std::vector<char> genRandomData(const size_t size)
|
|
{
|
|
std::vector<char> v(size);
|
|
v.resize(size);
|
|
auto data = v.data();
|
|
for (size_t i = 0; i < size; ++i)
|
|
{
|
|
data[i] = static_cast<char>(Util::rng::getNext());
|
|
}
|
|
|
|
return v;
|
|
}
|
|
|
|
inline
|
|
std::string genRandomString(const size_t size)
|
|
{
|
|
std::string text;
|
|
text.reserve(size);
|
|
for (size_t i = 0; i < size; ++i)
|
|
{
|
|
text += static_cast<char>('!' + Util::rng::getNext() % 95);
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
inline
|
|
std::vector<char> readDataFromFile(const std::string& filename)
|
|
{
|
|
std::ifstream ifs(Poco::Path(TDOC, filename).toString(), std::ios::binary);
|
|
|
|
// Apparently std::ios::binary is not good
|
|
// enough to stop eating new-line chars!
|
|
ifs.unsetf(std::ios::skipws);
|
|
|
|
std::istream_iterator<char> start(ifs);
|
|
std::istream_iterator<char> end;
|
|
return std::vector<char>(start, end);
|
|
}
|
|
|
|
inline
|
|
std::vector<char> readDataFromFile(std::unique_ptr<std::fstream>& file)
|
|
{
|
|
file->seekg(0, std::ios_base::end);
|
|
const std::streamsize size = file->tellg();
|
|
|
|
std::vector<char> v;
|
|
v.resize(size);
|
|
|
|
file->seekg(0, std::ios_base::beg);
|
|
file->read(v.data(), size);
|
|
|
|
return v;
|
|
}
|
|
|
|
inline
|
|
void getDocumentPathAndURL(const std::string& docFilename, std::string& documentPath, std::string& documentURL)
|
|
{
|
|
documentPath = FileUtil::getTempFilePath(TDOC, docFilename);
|
|
std::string encodedUri;
|
|
Poco::URI::encode("file://" + Poco::Path(documentPath).makeAbsolute().toString(), ":/?", encodedUri);
|
|
documentURL = "lool/" + encodedUri + "/ws";
|
|
std::cerr << "Test file: " << documentPath << std::endl;
|
|
}
|
|
|
|
inline
|
|
void sendTextFrame(LOOLWebSocket& socket, const std::string& string, const std::string& name = "")
|
|
{
|
|
std::cerr << name << "Sending " << string.size() << " bytes: " << LOOLProtocol::getAbbreviatedMessage(string) << std::endl;
|
|
socket.sendFrame(string.data(), string.size());
|
|
}
|
|
|
|
inline
|
|
void sendTextFrame(const std::shared_ptr<LOOLWebSocket>& socket, const std::string& string, const std::string& name = "")
|
|
{
|
|
sendTextFrame(*socket, string, name);
|
|
}
|
|
|
|
inline
|
|
Poco::Net::HTTPClientSession* createSession(const Poco::URI& uri)
|
|
{
|
|
#if ENABLE_SSL
|
|
return new Poco::Net::HTTPSClientSession(uri.getHost(), uri.getPort());
|
|
#else
|
|
return new Poco::Net::HTTPClientSession(uri.getHost(), uri.getPort());
|
|
#endif
|
|
}
|
|
|
|
inline
|
|
std::string getTestServerURI()
|
|
{
|
|
static const char* clientPort = std::getenv("LOOL_TEST_CLIENT_PORT");
|
|
|
|
static std::string serverURI(
|
|
#if ENABLE_SSL
|
|
"https://127.0.0.1:"
|
|
#else
|
|
"http://127.0.0.1:"
|
|
#endif
|
|
+ (clientPort? std::string(clientPort) : std::to_string(DEFAULT_CLIENT_PORT_NUMBER)));
|
|
|
|
return serverURI;
|
|
}
|
|
|
|
inline
|
|
int getErrorCode(LOOLWebSocket& ws, std::string& message)
|
|
{
|
|
int flags = 0;
|
|
int bytes = 0;
|
|
Poco::UInt16 statusCode = -1;
|
|
Poco::Buffer<char> buffer(READ_BUFFER_SIZE);
|
|
|
|
message.clear();
|
|
Poco::Timespan timeout(5000000);
|
|
ws.setReceiveTimeout(timeout);
|
|
do
|
|
{
|
|
bytes = ws.receiveFrame(buffer.begin(), READ_BUFFER_SIZE, flags);
|
|
}
|
|
while ((flags & Poco::Net::WebSocket::FRAME_OP_BITMASK) != Poco::Net::WebSocket::FRAME_OP_CLOSE);
|
|
|
|
if (bytes > 0)
|
|
{
|
|
Poco::MemoryBinaryReader reader(buffer, Poco::BinaryReader::NETWORK_BYTE_ORDER);
|
|
reader >> statusCode;
|
|
message.append(buffer.begin() + 2, bytes - 2);
|
|
}
|
|
|
|
return statusCode;
|
|
}
|
|
|
|
inline
|
|
std::vector<char> getResponseMessage(LOOLWebSocket& ws, const std::string& prefix, std::string name = "", const size_t timeoutMs = 10000)
|
|
{
|
|
name = name + '[' + prefix + "] ";
|
|
try
|
|
{
|
|
int flags = 0;
|
|
int retries = timeoutMs / 500;
|
|
const Poco::Timespan waitTime(retries ? timeoutMs * 1000 / retries : timeoutMs * 1000);
|
|
std::vector<char> response;
|
|
|
|
bool timedout = false;
|
|
ws.setReceiveTimeout(0);
|
|
do
|
|
{
|
|
if (ws.poll(waitTime, Poco::Net::Socket::SELECT_READ))
|
|
{
|
|
if (timedout)
|
|
{
|
|
std::cerr << std::endl;
|
|
timedout = false;
|
|
}
|
|
|
|
response.resize(READ_BUFFER_SIZE);
|
|
int bytes = ws.receiveFrame(response.data(), response.size(), flags);
|
|
response.resize(std::max(bytes, 0));
|
|
std::cerr << name << "Got " << LOOLProtocol::getAbbreviatedFrameDump(response.data(), bytes, flags) << std::endl;
|
|
const auto message = LOOLProtocol::getFirstLine(response);
|
|
if (bytes > 0 && (flags & Poco::Net::WebSocket::FRAME_OP_BITMASK) != Poco::Net::WebSocket::FRAME_OP_CLOSE)
|
|
{
|
|
if (LOOLProtocol::matchPrefix(prefix, message))
|
|
{
|
|
return response;
|
|
}
|
|
else if (LOOLProtocol::matchPrefix("nextmessage", message))
|
|
{
|
|
int size = 0;
|
|
if (LOOLProtocol::getTokenIntegerFromMessage(message, "size", size) && size > 0)
|
|
{
|
|
response.resize(size);
|
|
bytes = ws.receiveFrame(response.data(), response.size(), flags);
|
|
response.resize(std::max(bytes, 0));
|
|
std::cerr << name << "Got " << LOOLProtocol::getAbbreviatedFrameDump(response.data(), bytes, flags) << std::endl;
|
|
if (bytes > 0 &&
|
|
LOOLProtocol::matchPrefix(prefix, LOOLProtocol::getFirstLine(response)))
|
|
{
|
|
return response;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
response.resize(0);
|
|
}
|
|
|
|
if (bytes <= 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if ((flags & Poco::Net::WebSocket::FRAME_OP_BITMASK) != Poco::Net::WebSocket::FRAME_OP_CLOSE)
|
|
{
|
|
// Don't ignore errors.
|
|
if (LOOLProtocol::matchPrefix("error:", message))
|
|
{
|
|
throw std::runtime_error(message);
|
|
}
|
|
|
|
std::cerr << name << "Ignored: " << message << std::endl;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (!timedout)
|
|
{
|
|
std::cerr << name << "Timeout ";
|
|
}
|
|
else
|
|
{
|
|
std::cerr << retries << ' ';
|
|
}
|
|
|
|
--retries;
|
|
timedout = true;
|
|
}
|
|
}
|
|
while (retries > 0 && (flags & Poco::Net::WebSocket::FRAME_OP_BITMASK) != Poco::Net::WebSocket::FRAME_OP_CLOSE);
|
|
|
|
if (timedout)
|
|
{
|
|
std::cerr << std::endl;
|
|
}
|
|
}
|
|
catch (const Poco::Net::WebSocketException& exc)
|
|
{
|
|
std::cerr << std::endl << exc.message();
|
|
}
|
|
|
|
return std::vector<char>();
|
|
}
|
|
|
|
inline
|
|
std::vector<char> getResponseMessage(const std::shared_ptr<LOOLWebSocket>& ws, const std::string& prefix, const std::string& name = "", const size_t timeoutMs = 10000)
|
|
{
|
|
return getResponseMessage(*ws, prefix, name, timeoutMs);
|
|
}
|
|
|
|
template <typename T>
|
|
std::string getResponseString(T& ws, const std::string& prefix, const std::string& name = "", const size_t timeoutMs = 10000)
|
|
{
|
|
const auto response = getResponseMessage(ws, prefix, name, timeoutMs);
|
|
return std::string(response.data(), response.size());
|
|
}
|
|
|
|
template <typename T>
|
|
std::string assertResponseString(T& ws, const std::string& prefix, const std::string name = "")
|
|
{
|
|
const auto res = getResponseString(ws, prefix, name);
|
|
CPPUNIT_ASSERT_EQUAL(prefix, res.substr(0, prefix.length()));
|
|
return res;
|
|
}
|
|
|
|
/// Assert that we don't get a response with the given prefix.
|
|
template <typename T>
|
|
std::string assertNotInResponse(T& ws, const std::string& prefix, const std::string name = "")
|
|
{
|
|
const auto res = getResponseString(ws, prefix, name, 1000);
|
|
CPPUNIT_ASSERT_MESSAGE("Did not expect getting message [" + res + "].", res.empty());
|
|
return res;
|
|
}
|
|
|
|
inline
|
|
bool isDocumentLoaded(LOOLWebSocket& ws, const std::string& name = "", bool isView = true)
|
|
{
|
|
const std::string prefix = isView ? "status:" : "statusindicatorfinish:";
|
|
const auto message = getResponseString(ws, prefix, name);
|
|
return LOOLProtocol::matchPrefix(prefix, message);
|
|
}
|
|
|
|
inline
|
|
bool isDocumentLoaded(std::shared_ptr<LOOLWebSocket>& ws, const std::string& name = "", bool isView = true)
|
|
{
|
|
return isDocumentLoaded(*ws, name, isView);
|
|
}
|
|
|
|
// Connecting to a Kit process is managed by document broker, that it does several
|
|
// jobs to establish the bridge connection between the Client and Kit process,
|
|
// The result, it is mostly time outs to get messages in the unit test and it could fail.
|
|
// connectLOKit ensures the websocket is connected to a kit process.
|
|
inline
|
|
std::shared_ptr<LOOLWebSocket>
|
|
connectLOKit(const Poco::URI& uri,
|
|
Poco::Net::HTTPRequest& request,
|
|
Poco::Net::HTTPResponse& response,
|
|
const std::string& name = "")
|
|
{
|
|
std::cerr << name << "Connecting... ";
|
|
int retries = 10;
|
|
do
|
|
{
|
|
try
|
|
{
|
|
std::unique_ptr<Poco::Net::HTTPClientSession> session(createSession(uri));
|
|
auto ws = std::make_shared<LOOLWebSocket>(*session, request, response);
|
|
const auto expected_response = "statusindicator: ready";
|
|
if (getResponseString(ws, expected_response, name) == expected_response)
|
|
{
|
|
return ws;
|
|
}
|
|
|
|
std::cerr << (11 - retries);
|
|
}
|
|
catch (const std::exception& ex)
|
|
{
|
|
std::cerr << std::endl << "Error connecting: " << ex.what() << std::endl;
|
|
}
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(POLL_TIMEOUT_MS));
|
|
}
|
|
while (retries--);
|
|
|
|
std::cerr << std::endl;
|
|
throw std::runtime_error("Cannot connect to [" + uri.toString() + "].");
|
|
}
|
|
|
|
inline
|
|
std::shared_ptr<LOOLWebSocket> loadDocAndGetSocket(const Poco::URI& uri, const std::string& documentURL, const std::string& name = "", bool isView = true)
|
|
{
|
|
try
|
|
{
|
|
// Load a document and get its status.
|
|
Poco::Net::HTTPRequest request(Poco::Net::HTTPRequest::HTTP_GET, documentURL);
|
|
Poco::Net::HTTPResponse response;
|
|
auto socket = connectLOKit(uri, request, response, name);
|
|
|
|
sendTextFrame(socket, "load url=" + documentURL, name);
|
|
CPPUNIT_ASSERT_MESSAGE("cannot load the document " + documentURL, isDocumentLoaded(*socket, name, isView));
|
|
|
|
std::cerr << name << "Loaded document [" << documentURL << "]." << std::endl;
|
|
return socket;
|
|
}
|
|
catch (const Poco::Exception& exc)
|
|
{
|
|
CPPUNIT_FAIL(exc.displayText());
|
|
}
|
|
|
|
// Really couldn't reach here, but the compiler doesn't know any better.
|
|
return nullptr;
|
|
}
|
|
|
|
inline
|
|
std::shared_ptr<LOOLWebSocket> loadDocAndGetSocket(const std::string& docFilename, const Poco::URI& uri, const std::string& name = "", bool isView = true)
|
|
{
|
|
try
|
|
{
|
|
std::string documentPath, documentURL;
|
|
getDocumentPathAndURL(docFilename, documentPath, documentURL);
|
|
return loadDocAndGetSocket(uri, documentURL, name, isView);
|
|
}
|
|
catch (const Poco::Exception& exc)
|
|
{
|
|
CPPUNIT_FAIL(exc.displayText());
|
|
}
|
|
|
|
// Really couldn't reach here, but the compiler doesn't know any better.
|
|
return nullptr;
|
|
}
|
|
|
|
inline
|
|
void SocketProcessor(const std::string& name,
|
|
const std::shared_ptr<LOOLWebSocket>& socket,
|
|
const std::function<bool(const std::string& msg)>& handler,
|
|
const size_t timeoutMs = 10000)
|
|
{
|
|
socket->setReceiveTimeout(0);
|
|
|
|
const Poco::Timespan waitTime(timeoutMs * 1000);
|
|
int flags = 0;
|
|
int n = 0;
|
|
char buffer[READ_BUFFER_SIZE];
|
|
do
|
|
{
|
|
if (!socket->poll(waitTime, Poco::Net::Socket::SELECT_READ))
|
|
{
|
|
std::cerr << name << "Timeout polling." << std::endl;
|
|
break;
|
|
}
|
|
|
|
n = socket->receiveFrame(buffer, sizeof(buffer), flags);
|
|
std::cerr << name << "Got " << LOOLProtocol::getAbbreviatedFrameDump(buffer, n, flags) << std::endl;
|
|
if (n > 0 && (flags & Poco::Net::WebSocket::FRAME_OP_BITMASK) != Poco::Net::WebSocket::FRAME_OP_CLOSE)
|
|
{
|
|
if (!handler(std::string(buffer, n)))
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
while (n > 0 && (flags & Poco::Net::WebSocket::FRAME_OP_BITMASK) != Poco::Net::WebSocket::FRAME_OP_CLOSE);
|
|
}
|
|
|
|
inline
|
|
void parseDocSize(const std::string& message, const std::string& type,
|
|
int& part, int& parts, int& width, int& height, int& viewid)
|
|
{
|
|
Poco::StringTokenizer tokens(message, " ", Poco::StringTokenizer::TOK_IGNORE_EMPTY | Poco::StringTokenizer::TOK_TRIM);
|
|
|
|
// Expected format is something like 'type= parts= current= width= height='.
|
|
const std::string text = tokens[0].substr(std::string("type=").size());
|
|
parts = std::stoi(tokens[1].substr(std::string("parts=").size()));
|
|
part = std::stoi(tokens[2].substr(std::string("current=").size()));
|
|
width = std::stoi(tokens[3].substr(std::string("width=").size()));
|
|
height = std::stoi(tokens[4].substr(std::string("height=").size()));
|
|
viewid = std::stoi(tokens[5].substr(std::string("viewid=").size()));
|
|
CPPUNIT_ASSERT_EQUAL(type, text);
|
|
CPPUNIT_ASSERT(parts > 0);
|
|
CPPUNIT_ASSERT(part >= 0);
|
|
CPPUNIT_ASSERT(width > 0);
|
|
CPPUNIT_ASSERT(height > 0);
|
|
CPPUNIT_ASSERT(viewid >= 0);
|
|
}
|
|
|
|
inline
|
|
std::vector<char> getTileMessage(LOOLWebSocket& ws, const std::string& name = "")
|
|
{
|
|
return getResponseMessage(ws, "tile", name);
|
|
}
|
|
|
|
inline
|
|
std::vector<char> assertTileMessage(LOOLWebSocket& ws, const std::string& name = "")
|
|
{
|
|
const auto response = getTileMessage(ws, name);
|
|
|
|
const std::string firstLine = LOOLProtocol::getFirstLine(response);
|
|
Poco::StringTokenizer tileTokens(firstLine, " ", Poco::StringTokenizer::TOK_IGNORE_EMPTY | Poco::StringTokenizer::TOK_TRIM);
|
|
CPPUNIT_ASSERT_EQUAL(std::string("tile:"), tileTokens[0]);
|
|
CPPUNIT_ASSERT_EQUAL(std::string("part="), tileTokens[1].substr(0, std::string("part=").size()));
|
|
CPPUNIT_ASSERT_EQUAL(std::string("width="), tileTokens[2].substr(0, std::string("width=").size()));
|
|
CPPUNIT_ASSERT_EQUAL(std::string("height="), tileTokens[3].substr(0, std::string("height=").size()));
|
|
CPPUNIT_ASSERT_EQUAL(std::string("tileposx="), tileTokens[4].substr(0, std::string("tileposx=").size()));
|
|
CPPUNIT_ASSERT_EQUAL(std::string("tileposy="), tileTokens[5].substr(0, std::string("tileposy=").size()));
|
|
CPPUNIT_ASSERT_EQUAL(std::string("tilewidth="), tileTokens[6].substr(0, std::string("tilewidth=").size()));
|
|
CPPUNIT_ASSERT_EQUAL(std::string("tileheight="), tileTokens[7].substr(0, std::string("tileheight=").size()));
|
|
|
|
return response;
|
|
}
|
|
|
|
inline
|
|
std::vector<char> assertTileMessage(const std::shared_ptr<LOOLWebSocket>& ws, const std::string& name = "")
|
|
{
|
|
return assertTileMessage(*ws, name);
|
|
}
|
|
|
|
enum SpecialKey { skNone=0, skShift=0x1000, skCtrl=0x2000, skAlt=0x4000 };
|
|
|
|
inline int getCharChar(char ch, SpecialKey specialKeys)
|
|
{
|
|
// Some primitive code just suitable to basic needs of specific test.
|
|
// TODO: improve as appropriate.
|
|
if (specialKeys & (skCtrl | skAlt))
|
|
return 0;
|
|
|
|
switch (ch)
|
|
{
|
|
case '\x0a': // Enter
|
|
return 13;
|
|
default:
|
|
return ch;
|
|
}
|
|
}
|
|
|
|
inline int getCharKey(char ch, SpecialKey specialKeys)
|
|
{
|
|
// Some primitive code just suitable to basic needs of specific test.
|
|
// TODO: improve as appropriate.
|
|
int result;
|
|
switch (ch)
|
|
{
|
|
case '\x0a': // Enter
|
|
result = 1280;
|
|
break;
|
|
default:
|
|
result = ch;
|
|
}
|
|
return result | specialKeys;
|
|
}
|
|
|
|
inline void sendKeyEvent(std::shared_ptr<LOOLWebSocket>& socket, const char* type, int chr, int key, const std::string& testname = "")
|
|
{
|
|
std::ostringstream ssIn;
|
|
ssIn << "key type=" << type << " char=" << chr << " key=" << key;
|
|
sendTextFrame(socket, ssIn.str(), testname);
|
|
}
|
|
|
|
inline void sendKeyPress(std::shared_ptr<LOOLWebSocket>& socket, int chr, int key, const std::string& testname)
|
|
{
|
|
sendKeyEvent(socket, "input", chr, key, testname);
|
|
sendKeyEvent(socket, "up", chr, key, testname);
|
|
}
|
|
|
|
inline void sendChar(std::shared_ptr<LOOLWebSocket>& socket, char ch, SpecialKey specialKeys=skNone, const std::string& testname = "")
|
|
{
|
|
sendKeyPress(socket, getCharChar(ch, specialKeys), getCharKey(ch, specialKeys), testname);
|
|
}
|
|
|
|
inline void sendText(std::shared_ptr<LOOLWebSocket>& socket, const std::string& text, const std::string& testname = "")
|
|
{
|
|
for (char ch : text)
|
|
{
|
|
sendChar(socket, ch, skNone, testname);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/* vim:set shiftwidth=4 softtabstop=4 expandtab: */
|