diff --git a/CMakeLists.txt b/CMakeLists.txt index 73a91fa..6a917f9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,11 +5,16 @@ set(QT_MIN_VERSION "6.6.0") set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) +option(TESTS "Build tests" OFF) + +option(SOCKETS "Enable unix socket support" ON) option(WAYLAND "Enable wayland support" ON) option(WAYLAND_WLR_LAYERSHELL "Support the zwlr_layer_shell_v1 wayland protocol" ON) option(WAYLAND_SESSION_LOCK "Support the ext_session_lock_v1 wayland protocol" ON) message(STATUS "Quickshell configuration") +message(STATUS " Build tests: ${TESTS}") +message(STATUS " Sockets: ${SOCKETS}") message(STATUS " Wayland: ${WAYLAND}") if (WAYLAND) message(STATUS " Wlroots Layershell: ${WAYLAND_WLR_LAYERSHELL}") @@ -31,6 +36,16 @@ endif() set(QT_DEPS Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2) set(QT_FPDEPS Gui Qml Quick QuickControls2) +if (TESTS) + enable_testing() + list(APPEND QT_FPDEPS Test) +endif() + +if (SOCKETS) + list(APPEND QT_DEPS Qt6::Network) + list(APPEND QT_FPDEPS Network) +endif() + if (WAYLAND) list(APPEND QT_DEPS Qt6::WaylandClient Qt6::WaylandClientPrivate) list(APPEND QT_FPDEPS WaylandClient) diff --git a/Justfile b/Justfile index 1f688ea..3de65e9 100644 --- a/Justfile +++ b/Justfile @@ -25,3 +25,6 @@ clean: run *ARGS='': build {{builddir}}/src/core/quickshell {{ARGS}} + +test *ARGS='': build + ctest --test-dir build --output-on-failure {{ARGS}} diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index a41f52c..3442405 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -15,8 +15,17 @@ qt_add_executable(quickshell windowinterface.cpp floatingwindow.cpp panelinterface.cpp + datastream.cpp ) qt_add_qml_module(quickshell URI Quickshell) +if (SOCKETS) + target_sources(quickshell PRIVATE socket.cpp) +endif() + target_link_libraries(quickshell PRIVATE ${QT_DEPS}) + +if (TESTS) + add_subdirectory(test) +endif() diff --git a/src/core/datastream.cpp b/src/core/datastream.cpp new file mode 100644 index 0000000..01a7364 --- /dev/null +++ b/src/core/datastream.cpp @@ -0,0 +1,108 @@ +#include "datastream.hpp" +#include +#include + +#include +#include +#include +#include + +DataStreamParser* DataStream::reader() const { return this->mReader; } + +void DataStream::setReader(DataStreamParser* reader) { + if (reader == this->mReader) return; + + if (this->mReader != nullptr) { + QObject::disconnect(this->mReader, nullptr, this, nullptr); + } + + this->mReader = reader; + + if (reader != nullptr) { + QObject::connect(reader, &QObject::destroyed, this, &DataStream::onReaderDestroyed); + } + + emit this->readerChanged(); + + if (reader != nullptr && !this->buffer.isEmpty()) { + reader->parseBytes(this->buffer, this->buffer); + } +} + +void DataStream::onReaderDestroyed() { + this->mReader = nullptr; + emit this->readerChanged(); +} + +void DataStream::onBytesAvailable() { + auto buf = this->ioDevice()->readAll(); + this->mReader->parseBytes(buf, this->buffer); +} + +void SplitParser::parseBytes(QByteArray& incoming, QByteArray& buffer) { + if (this->mSplitMarker.isEmpty()) { + if (!buffer.isEmpty()) { + emit this->read(QString(buffer)); + buffer.clear(); + } + + emit this->read(QString(incoming)); + return; + } + + // make sure we dont miss any delimiters in the buffer if the delimiter changes + if (this->mSplitMarkerChanged) { + this->mSplitMarkerChanged = false; + this->parseBytes(buffer, buffer); + } + + auto marker = this->mSplitMarker.toUtf8(); + auto mlen = marker.length(); + + auto blen = buffer.size(); + auto ilen = incoming.size(); + + qsizetype start = &incoming == &buffer ? 0 : -blen; + for (auto readi = -std::min(blen, mlen - 1); readi <= ilen - mlen; readi++) { + for (auto marki = 0; marki < mlen; marki++) { + qint8 byte; // NOLINT + if (readi + marki < 0) byte = buffer[blen + readi + marki]; + else byte = incoming[readi + marki]; + + if (byte != marker[marki]) goto fail; + } + + { + QByteArray slice; + if (start < 0) slice = buffer.sliced(0, std::min(blen, blen + readi)); + if (readi > 0) { + auto sstart = std::max(static_cast(0), start); + slice.append(incoming.sliced(sstart, readi - sstart)); + } + readi += mlen; + start = readi; + emit this->read(QString(slice)); + } + + fail:; + } + + if (start < 0) { + buffer.append(incoming); + } else { + // Will break the init case if inlined. Must be before clear. + auto slice = incoming.sliced(start); + buffer.clear(); + buffer.insert(0, slice); + } +} + +QString SplitParser::splitMarker() const { return this->mSplitMarker; } + +void SplitParser::setSplitMarker(QString marker) { + if (marker == this->mSplitMarker) return; + + this->mSplitMarker = std::move(marker); + this->mSplitMarkerChanged = true; + emit this->splitMarkerChanged(); +} diff --git a/src/core/datastream.hpp b/src/core/datastream.hpp new file mode 100644 index 0000000..13baf28 --- /dev/null +++ b/src/core/datastream.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class DataStreamParser; + +///! Data source that can be streamed into a parser. +/// See also: [DataStreamParser](../datastreamparser) +class DataStream: public QObject { + Q_OBJECT; + /// The parser to stream data from this source into. + /// If the parser is null no data will be read. + Q_PROPERTY(DataStreamParser* parser READ reader WRITE setReader NOTIFY readerChanged); + QML_ELEMENT; + QML_UNCREATABLE("base class"); + +public: + explicit DataStream(QObject* parent = nullptr): QObject(parent) {} + + [[nodiscard]] DataStreamParser* reader() const; + void setReader(DataStreamParser* reader); + +signals: + void readerChanged(); + +public slots: + void onBytesAvailable(); + +protected: + [[nodiscard]] virtual QIODevice* ioDevice() const = 0; + +private slots: + void onReaderDestroyed(); + +private: + DataStreamParser* mReader = nullptr; + QByteArray buffer; +}; + +///! Parser for streamed input data. +/// See also: [DataStream](../datastream), [SplitParser](../splitparser) +class DataStreamParser: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE("base class"); + +public: + explicit DataStreamParser(QObject* parent = nullptr): QObject(parent) {} + + // the buffer will be sent in both slots if there is data remaining from a previous parser + virtual void parseBytes(QByteArray& incoming, QByteArray& buffer) = 0; + +signals: + /// Emitted when data is read from the stream. + void read(QString data); +}; + +///! Parser for delimited data streams. +/// Parser for delimited data streams. [read()] is emitted once per delimited chunk of the stream. +/// +/// [read()]: ../datastreamparser#sig.read +class SplitParser: public DataStreamParser { + Q_OBJECT; + /// The delimiter for parsed data. May be multiple characters. Defaults to `\n`. + /// + /// If the delimiter is empty read lengths may be arbitrary (whatever is returned by the + /// underlying read call.) + Q_PROPERTY(QString splitMarker READ splitMarker WRITE setSplitMarker NOTIFY splitMarkerChanged); + QML_ELEMENT; + +public: + explicit SplitParser(QObject* parent = nullptr): DataStreamParser(parent) {} + + void parseBytes(QByteArray& incoming, QByteArray& buffer) override; + + [[nodiscard]] QString splitMarker() const; + void setSplitMarker(QString marker); + +signals: + void splitMarkerChanged(); + +private: + QString mSplitMarker = "\n"; + bool mSplitMarkerChanged = false; +}; diff --git a/src/core/module.md b/src/core/module.md index 70b2c8c..b94d491 100644 --- a/src/core/module.md +++ b/src/core/module.md @@ -12,5 +12,7 @@ headers = [ "windowinterface.hpp", "panelinterface.hpp", "floatingwindow.hpp", + "datastream.hpp", + "socket.hpp", ] ----- diff --git a/src/core/socket.cpp b/src/core/socket.cpp new file mode 100644 index 0000000..9610848 --- /dev/null +++ b/src/core/socket.cpp @@ -0,0 +1,77 @@ +#include "socket.hpp" +#include + +#include +#include +#include + +#include "datastream.hpp" + +void Socket::setSocket(QLocalSocket* socket) { + if (this->socket != nullptr) this->socket->deleteLater(); + + this->socket = socket; + socket->setParent(this); + + if (socket != nullptr) { + // clang-format off + QObject::connect(this->socket, &QLocalSocket::connected, this, &Socket::onSocketConnected); + QObject::connect(this->socket, &QLocalSocket::disconnected, this, &Socket::onSocketDisconnected); + QObject::connect(this->socket, &QLocalSocket::errorOccurred, this, &Socket::error); + QObject::connect(this->socket, &QLocalSocket::readyRead, this, &DataStream::onBytesAvailable); + // clang-format on + + if (socket->isOpen()) this->onSocketConnected(); + } +} + +QString Socket::path() const { return this->mPath; } + +void Socket::setPath(QString path) { + if ((this->connected && !this->disconnecting) || path == this->mPath) return; + this->mPath = std::move(path); + emit this->pathChanged(); + + if (this->targetConnected && this->socket == nullptr) this->connectPathSocket(); +} + +void Socket::onSocketConnected() { + this->connected = true; + this->targetConnected = false; + this->disconnecting = false; + emit this->connectionStateChanged(); +} + +void Socket::onSocketDisconnected() { + this->connected = false; + this->disconnecting = false; + this->socket->deleteLater(); + this->socket = nullptr; + emit this->connectionStateChanged(); + + if (this->targetConnected) this->connectPathSocket(); +} + +bool Socket::isConnected() const { return this->connected; } + +void Socket::setConnected(bool connected) { + this->targetConnected = connected; + + if (!connected) { + if (this->socket != nullptr && !this->disconnecting) { + this->disconnecting = true; + this->socket->disconnectFromServer(); + } + } else if (this->socket == nullptr) this->connectPathSocket(); +} + +QIODevice* Socket::ioDevice() const { return this->socket; } + +void Socket::connectPathSocket() { + if (!this->mPath.isEmpty()) { + auto* socket = new QLocalSocket(); + socket->setServerName(this->mPath); + this->setSocket(socket); + this->socket->connectToServer(QIODevice::ReadWrite); + } +} diff --git a/src/core/socket.hpp b/src/core/socket.hpp new file mode 100644 index 0000000..9742e1d --- /dev/null +++ b/src/core/socket.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include + +#include "datastream.hpp" + +///! Unix socket listener. +class Socket: public DataStream { + Q_OBJECT; + /// Returns if the socket is currently connected. + /// + /// Writing to this property will set the target connection state and will not + /// update the property immediately. Setting the property to false will begin disconnecting + /// the socket, and setting it to true will begin connecting the socket if path is not empty. + Q_PROPERTY(bool connected READ isConnected WRITE setConnected NOTIFY connectionStateChanged); + /// The path to connect this socket to when `connected` is set to true. + /// + /// Changing this property will have no effect while the connection is active. + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged); + QML_ELEMENT; + +public: + explicit Socket(QObject* parent = nullptr): DataStream(parent) {} + + // takes ownership + void setSocket(QLocalSocket* socket); + + [[nodiscard]] bool isConnected() const; + void setConnected(bool connected); + + [[nodiscard]] QString path() const; + void setPath(QString path); + +signals: + /// This signal is sent whenever a socket error is encountered. + void error(QLocalSocket::LocalSocketError error); + + void connectionStateChanged(); + void pathChanged(); + +protected: + [[nodiscard]] QIODevice* ioDevice() const override; + +private slots: + void onSocketConnected(); + void onSocketDisconnected(); + +private: + void connectPathSocket(); + + QLocalSocket* socket = nullptr; + bool connected = false; + bool disconnecting = false; + bool targetConnected = false; + QString mPath; +}; diff --git a/src/core/test/CMakeLists.txt b/src/core/test/CMakeLists.txt new file mode 100644 index 0000000..0c0cfc5 --- /dev/null +++ b/src/core/test/CMakeLists.txt @@ -0,0 +1,7 @@ +function (qs_test name) + add_executable(${name} ${ARGN}) + target_link_libraries(${name} PRIVATE ${QT_DEPS} Qt6::Test) + add_test(NAME ${name} WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" COMMAND $) +endfunction() + +qs_test(datastream datastream.cpp ../datastream.cpp) diff --git a/src/core/test/datastream.cpp b/src/core/test/datastream.cpp new file mode 100644 index 0000000..f0f36be --- /dev/null +++ b/src/core/test/datastream.cpp @@ -0,0 +1,110 @@ +#include "../datastream.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +class TestSplitParser: public QObject { + Q_OBJECT; +private slots: + void splits_data() { // NOLINT + QTest::addColumn("mark"); + QTest::addColumn("buffer"); // max that can go in the buffer + QTest::addColumn("incoming"); // data that has to be tested on the end in one go + QTest::addColumn>("results"); + QTest::addColumn("remainder"); + + // NOLINTBEGIN + // clang-format off + QTest::addRow("simple") << "-" + << "foo" << "-" + << QList("foo") << ""; + + QTest::addRow("multiple") << "-" + << "foo" << "-bar-baz-" + << QList({ "foo", "bar", "baz" }) << ""; + + QTest::addRow("incomplete") << "-" + << "foo" << "-bar-baz" + << QList({ "foo", "bar" }) << "baz"; + + QTest::addRow("longsplit") << "12345" + << "foo1234" << "5bar12345" + << QList({ "foo", "bar" }) << ""; + + QTest::addRow("longsplit-incomplete") << "123" + << "foo12" << "3bar123baz" + << QList({ "foo", "bar" }) << "baz"; + // clang-format on + // NOLINTEND + } + + void splits() { // NOLINT + // NOLINTBEGIN + QFETCH(QString, mark); + QFETCH(QString, buffer); + QFETCH(QString, incoming); + QFETCH(QList, results); + QFETCH(QString, remainder); + // NOLINTEND + + auto bufferArray = buffer.toUtf8(); + auto incomingArray = incoming.toUtf8(); + + for (auto i = 0; i <= bufferArray.length(); i++) { + auto buffer = bufferArray.sliced(0, i); + auto incoming = bufferArray.sliced(i); + incoming.append(incomingArray); + + qInfo() << "BUFFER" << QString(buffer); + qInfo() << "INCOMING" << QString(incoming); + + auto parser = SplitParser(); + auto spy = QSignalSpy(&parser, &DataStreamParser::read); + + parser.setSplitMarker(mark); + parser.parseBytes(incoming, buffer); + + auto actualResults = QList(); + for (auto& read: spy) { + actualResults.push_back(read[0].toString()); + } + + qInfo() << "EXPECTED RESULTS" << results; + qInfo() << "ACTUAL RESULTS" << actualResults; + qInfo() << "EXPECTED REMAINDER" << remainder; + qInfo() << "ACTUAL REMAINDER" << remainder; + QCOMPARE(actualResults, results); + QCOMPARE(buffer, remainder); + } + } + + void initBuffer() { // NOLINT + auto parser = SplitParser(); + auto spy = QSignalSpy(&parser, &DataStreamParser::read); + + auto buf = QString("foo-bar-baz").toUtf8(); + auto expected = QList({"foo", "bar"}); + + parser.setSplitMarker("-"); + parser.parseBytes(buf, buf); + + auto actualResults = QList(); + for (auto& read: spy) { + actualResults.push_back(read[0].toString()); + } + + qInfo() << "EXPECTED RESULTS" << expected; + qInfo() << "ACTUAL RESULTS" << actualResults; + QCOMPARE(actualResults, expected); + QCOMPARE(buf, "baz"); + } +}; + +QTEST_MAIN(TestSplitParser) +#include "datastream.moc"