From ab66be21b37c71037aa00fdbb24103759225a1a1 Mon Sep 17 00:00:00 2001 From: nerix Date: Mon, 7 Apr 2025 19:38:10 +0200 Subject: [PATCH] feat(plugins): add `c2.WebSocket` (#6076) --- .github/workflows/test-arch-linux.yml | 4 +- .github/workflows/test-macos.yml | 4 +- .github/workflows/test-windows.yml | 4 +- .github/workflows/test.yml | 4 +- CHANGELOG.md | 1 + docs/chatterino.d.ts | 24 +- docs/plugin-meta.lua | 33 ++ src/CMakeLists.txt | 11 + src/common/websockets/WebSocketPool.cpp | 125 ++++++ src/common/websockets/WebSocketPool.hpp | 82 ++++ .../websockets/detail/WebSocketConnection.cpp | 53 +++ .../websockets/detail/WebSocketConnection.hpp | 81 ++++ .../detail/WebSocketConnectionImpl.cpp | 388 ++++++++++++++++++ .../detail/WebSocketConnectionImpl.hpp | 115 ++++++ .../websockets/detail/WebSocketPoolImpl.cpp | 96 +++++ .../websockets/detail/WebSocketPoolImpl.hpp | 51 +++ src/controllers/plugins/LuaAPI.hpp | 1 + src/controllers/plugins/Plugin.cpp | 7 + src/controllers/plugins/Plugin.hpp | 1 + src/controllers/plugins/PluginController.cpp | 11 + src/controllers/plugins/PluginController.hpp | 4 + src/controllers/plugins/SolTypes.cpp | 11 +- src/controllers/plugins/SolTypes.hpp | 29 +- src/controllers/plugins/api/WebSocket.cpp | 175 ++++++++ src/controllers/plugins/api/WebSocket.hpp | 78 ++++ src/util/PostToThread.hpp | 32 +- src/util/QByteArrayBuffer.hpp | 120 ++++++ tests/CMakeLists.txt | 1 + tests/src/NetworkHelpers.hpp | 6 + tests/src/Plugins.cpp | 173 ++++++++ tests/src/WebSocketPool.cpp | 163 ++++++++ 31 files changed, 1859 insertions(+), 29 deletions(-) create mode 100644 src/common/websockets/WebSocketPool.cpp create mode 100644 src/common/websockets/WebSocketPool.hpp create mode 100644 src/common/websockets/detail/WebSocketConnection.cpp create mode 100644 src/common/websockets/detail/WebSocketConnection.hpp create mode 100644 src/common/websockets/detail/WebSocketConnectionImpl.cpp create mode 100644 src/common/websockets/detail/WebSocketConnectionImpl.hpp create mode 100644 src/common/websockets/detail/WebSocketPoolImpl.cpp create mode 100644 src/common/websockets/detail/WebSocketPoolImpl.hpp create mode 100644 src/controllers/plugins/api/WebSocket.cpp create mode 100644 src/controllers/plugins/api/WebSocket.hpp create mode 100644 src/util/QByteArrayBuffer.hpp create mode 100644 tests/src/WebSocketPool.cpp diff --git a/.github/workflows/test-arch-linux.yml b/.github/workflows/test-arch-linux.yml index a0788205..1006145e 100644 --- a/.github/workflows/test-arch-linux.yml +++ b/.github/workflows/test-arch-linux.yml @@ -11,7 +11,7 @@ on: - main env: - TWITCH_PUBSUB_SERVER_TAG: v1.0.8 + TWITCH_PUBSUB_SERVER_TAG: v1.0.11 HTTPBOX_TAG: v0.2.1 QT_QPA_PLATFORM: minimal @@ -76,7 +76,7 @@ jobs: run: | httpbox --port 9051 & cd ../pubsub-server-test - ./server 127.0.0.1:9050 & + ./server 127.0.0.1:9050 127.0.0.1:9052 & cd ../build-test ctest --repeat until-pass:4 --output-on-failure working-directory: build-test diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml index 3399f84d..3b9a517a 100644 --- a/.github/workflows/test-macos.yml +++ b/.github/workflows/test-macos.yml @@ -7,7 +7,7 @@ on: merge_group: env: - TWITCH_PUBSUB_SERVER_TAG: v1.0.8 + TWITCH_PUBSUB_SERVER_TAG: v1.0.11 HTTPBOX_TAG: v0.2.1 QT_QPA_PLATFORM: minimal HOMEBREW_NO_AUTO_UPDATE: 1 @@ -80,7 +80,7 @@ jobs: run: | httpbox --port 9051 & cd ../pubsub-server-test - ./server 127.0.0.1:9050 & + ./server 127.0.0.1:9050 127.0.0.1:9052 & cd ../build-test ctest --repeat until-pass:4 --output-on-failure working-directory: build-test diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 235d819d..f1d50153 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -7,7 +7,7 @@ on: merge_group: env: - TWITCH_PUBSUB_SERVER_TAG: v1.0.8 + TWITCH_PUBSUB_SERVER_TAG: v1.0.11 HTTPBOX_TAG: v0.2.1 QT_QPA_PLATFORM: minimal CONAN_VERSION: 2.11.0 @@ -134,7 +134,7 @@ jobs: run: | ..\httpbox\httpbox.exe --port 9051 & cd ..\pubsub-server-test - .\server.exe 127.0.0.1:9050 & + ./server 127.0.0.1:9050 127.0.0.1:9052 & cd ..\build-test ctest --repeat until-pass:4 --output-on-failure working-directory: build-test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b043c13..5861c007 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ on: - main env: - TWITCH_PUBSUB_SERVER_TAG: v1.0.8 + TWITCH_PUBSUB_SERVER_TAG: v1.0.11 HTTPBOX_TAG: v0.2.1 QT_QPA_PLATFORM: minimal @@ -81,7 +81,7 @@ jobs: run: | httpbox --port 9051 & cd ../pubsub-server-test - ./server 127.0.0.1:9050 & + ./server 127.0.0.1:9050 127.0.0.1:9052 & cd ../build-test ctest --repeat until-pass:4 --output-on-failure working-directory: build-test diff --git a/CHANGELOG.md b/CHANGELOG.md index 8773bbbb..755c4f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Minor: Add an option for the reduced opacity of message history. (#6121) - Minor: Make paused chat indicator more visible, and fix its zoom behavior. (#6123) +- Minor: Added WebSocket API for plugins. (#6076) - Bugfix: Don't create native messaging manifest file if browser directory doesn't exist. (#6116) - Bugfix: Fixed scrolling now working on inputs in the settings. (#6128) - Bugfix: Make reply-cancel button less coarse-grained. (#6106) diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts index f2acaa69..05d7023b 100644 --- a/docs/chatterino.d.ts +++ b/docs/chatterino.d.ts @@ -1,6 +1,6 @@ /** @noSelfInFile */ -declare module c2 { +declare namespace c2 { enum LogLevel { Debug, Info, @@ -129,4 +129,26 @@ declare module c2 { function register_callback(type: T, func: CbFunc): void; function later(callback: () => void, msec: number): void; + + interface WebSocket { + close(): void; + send_text(data: string): void; + send_binary(data: string): void; + on_close: null | (() => void); + on_text: null | ((data: string) => void); + on_binary: null | ((data: string) => void); + } + interface WebSocketConstructor { + new: ( + this: void, + url: string, + options?: { + headers?: Record; + on_close?: () => void; + on_text?: (data: string) => void; + on_binary?: (data: string) => void; + } + ) => WebSocket; + } + var WebSocket: WebSocketConstructor; } diff --git a/docs/plugin-meta.lua b/docs/plugin-meta.lua index 828e9706..cac380ae 100644 --- a/docs/plugin-meta.lua +++ b/docs/plugin-meta.lua @@ -257,6 +257,39 @@ function c2.HTTPRequest.create(method, url) end -- End src/controllers/plugins/api/HTTPRequest.hpp +-- Begin src/controllers/plugins/api/WebSocket.hpp + +---@class c2.WebSocket +---@field on_close fun()|nil Handler called when the socket is closed. +---@field on_text fun(data: string)|nil Handler called when the socket receives a text message. +---@field on_binary fun(data: string)|nil Handler called when the socket receives a binary message. +c2.WebSocket = {} + +--- Creates and connects to a WebSocket server. Upon calling this, a +--- connection is made immediately. +--- +---@param url string The URL to connect to. Must start with `wss://` or `ws://`. +---@param options? { headers?: table, on_close?: fun(), on_text?: fun(data: string), on_binary?: fun(data: string) } Additional options for the connection. +---@return c2.WebSocket +---@nodiscard +function c2.WebSocket.new(url, options) end + +--- Closes the socket. +--- +function c2.WebSocket:close() end + +--- Sends a text message on the socket. +--- +---@param data string The text to send. +function c2.WebSocket:send_text(data) end + +--- Sends a binary message on the socket. +--- +---@param data string The binary data to send. +function c2.WebSocket:send_binary(data) end + +-- End src/controllers/plugins/api/WebSocket.hpp + -- Begin src/common/network/NetworkCommon.hpp ---@enum c2.HTTPMethod diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9bb402c7..ec134bea 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -57,6 +57,15 @@ set(SOURCE_FILES common/network/NetworkTask.cpp common/network/NetworkTask.hpp + common/websockets/WebSocketPool.cpp + common/websockets/WebSocketPool.hpp + common/websockets/detail/WebSocketConnection.cpp + common/websockets/detail/WebSocketConnection.hpp + common/websockets/detail/WebSocketConnectionImpl.cpp + common/websockets/detail/WebSocketConnectionImpl.hpp + common/websockets/detail/WebSocketPoolImpl.cpp + common/websockets/detail/WebSocketPoolImpl.hpp + controllers/accounts/Account.cpp controllers/accounts/Account.hpp controllers/accounts/AccountController.cpp @@ -238,6 +247,8 @@ set(SOURCE_FILES controllers/plugins/api/HTTPResponse.hpp controllers/plugins/api/IOWrapper.cpp controllers/plugins/api/IOWrapper.hpp + controllers/plugins/api/WebSocket.cpp + controllers/plugins/api/WebSocket.hpp controllers/plugins/LuaAPI.cpp controllers/plugins/LuaAPI.hpp controllers/plugins/LuaUtilities.cpp diff --git a/src/common/websockets/WebSocketPool.cpp b/src/common/websockets/WebSocketPool.cpp new file mode 100644 index 00000000..6e2e5301 --- /dev/null +++ b/src/common/websockets/WebSocketPool.cpp @@ -0,0 +1,125 @@ +#include "common/websockets/WebSocketPool.hpp" + +#include "common/QLogging.hpp" +#include "common/websockets/detail/WebSocketConnectionImpl.hpp" +#include "common/websockets/detail/WebSocketPoolImpl.hpp" + +namespace chatterino { + +WebSocketPool::WebSocketPool() = default; +WebSocketPool::~WebSocketPool() +{ + if (this->impl) + { + if (this->impl->tryShutdown(std::chrono::milliseconds{1000})) + { + qCDebug(chatterinoWebsocket) << "Closed gracefully."; + } + else + { + // Note: We can't detatch the IO-thread here but have to leak the + // pool, because the IO-thread still references the IO-context which + // is stored in the pool (otherwise we'd have a use-after-free). + qCWarning(chatterinoWebsocket) + << "Failed to shutdown within 1s, leaking"; + this->impl.release(); // NOLINT + } + } +} + +WebSocketHandle WebSocketPool::createSocket( + WebSocketOptions options, std::unique_ptr listener) +{ + if (!this->impl) + { + try + { + this->impl = std::make_unique(); + } + catch (const boost::system::system_error &err) + { + // This will only happen if the SSL context failed to be constructed. + // The user likely runs an incompatible OpenSSL version. + qCWarning(chatterinoWebsocket) + << "Failed to create WebSocket implementation" << err.what(); + return {{}}; + } + } + if (this->impl->closing) + { + return {{}}; + } + + std::shared_ptr conn; + + if (options.url.scheme() == "wss") + { + conn = std::make_shared( + std::move(options), this->impl->nextID++, std::move(listener), + this->impl.get(), this->impl->ioc, this->impl->ssl); + } + else if (options.url.scheme() == "ws") + { + conn = std::make_shared( + std::move(options), this->impl->nextID++, std::move(listener), + this->impl.get(), this->impl->ioc); + } + else + { + qCWarning(chatterinoWebsocket) << "Invalid scheme:" << options.url; + return {{}}; + } + + { + std::unique_lock guard(this->impl->connectionMutex); + this->impl->connections.push_back(conn); + } + + boost::asio::post(this->impl->ioc, [conn] { + conn->run(); + }); + + return {conn}; +} + +// MARK: WebSocketHandle + +WebSocketHandle::WebSocketHandle( + std::weak_ptr conn) + : conn(std::move(conn)) +{ +} + +WebSocketHandle::~WebSocketHandle() +{ + this->close(); +} + +void WebSocketHandle::close() +{ + auto strong = this->conn.lock(); + if (strong) + { + strong->close(); + } +} + +void WebSocketHandle::sendText(const QByteArray &data) +{ + auto strong = this->conn.lock(); + if (strong) + { + strong->sendText(data); + } +} + +void WebSocketHandle::sendBinary(const QByteArray &data) +{ + auto strong = this->conn.lock(); + if (strong) + { + strong->sendBinary(data); + } +} + +} // namespace chatterino diff --git a/src/common/websockets/WebSocketPool.hpp b/src/common/websockets/WebSocketPool.hpp new file mode 100644 index 00000000..93140f5c --- /dev/null +++ b/src/common/websockets/WebSocketPool.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include +#include + +#include + +namespace chatterino::ws::detail { +class WebSocketPoolImpl; +class WebSocketConnection; +} // namespace chatterino::ws::detail + +namespace chatterino { + +/// A handle to a websocket connection. +/// +/// Note that even though this handle only contains a weak pointer to the actual +/// connection, this handle controls the lifetime of the connection. Destroying +/// this handle will close the underlying connection gracefully (if possible). +/// It contains a weak pointer to avoid keeping the connection alive after the +/// parent pool has been destroyed. +class WebSocketHandle +{ +public: + WebSocketHandle() = default; + WebSocketHandle(std::weak_ptr conn); + ~WebSocketHandle(); + + WebSocketHandle(const WebSocketHandle &) = delete; + WebSocketHandle(WebSocketHandle &&) = default; + WebSocketHandle &operator=(const WebSocketHandle &) = delete; + WebSocketHandle &operator=(WebSocketHandle &&) = default; + + void sendText(const QByteArray &data); + void sendBinary(const QByteArray &data); + void close(); + +private: + std::weak_ptr conn; +}; + +struct WebSocketListener { + virtual ~WebSocketListener() = default; + + /// A text message was received. + /// + /// This function is called from the websocket thread. + virtual void onTextMessage(QByteArray data) = 0; + + /// A binary message was received. + /// + /// This function is called from the websocket thread. + virtual void onBinaryMessage(QByteArray data) = 0; + + /// The websocket was closed. + /// + /// This function is called from the websocket thread. + /// @param self The allocated listener (i.e. `self.get() == this`). Be + /// careful where this is destroyed. Once `self` is destroyed, + /// the instance of this class will be destroyed. + virtual void onClose(std::unique_ptr self) = 0; +}; + +struct WebSocketOptions { + QUrl url; + std::vector> headers; +}; + +class WebSocketPool +{ +public: + WebSocketPool(); + ~WebSocketPool(); + + [[nodiscard]] WebSocketHandle createSocket( + WebSocketOptions options, std::unique_ptr listener); + +private: + std::unique_ptr impl; +}; + +} // namespace chatterino diff --git a/src/common/websockets/detail/WebSocketConnection.cpp b/src/common/websockets/detail/WebSocketConnection.cpp new file mode 100644 index 00000000..16b5f2c9 --- /dev/null +++ b/src/common/websockets/detail/WebSocketConnection.cpp @@ -0,0 +1,53 @@ +#include "common/websockets/detail/WebSocketConnection.hpp" + +#include "common/QLogging.hpp" +#include "WebSocketPoolImpl.hpp" + +#include + +namespace chatterino::ws::detail { + +WebSocketConnection::WebSocketConnection( + WebSocketOptions options, int id, + std::unique_ptr listener, WebSocketPoolImpl *pool, + boost::asio::io_context &ioc) + : options(std::move(options)) + , listener(std::move(listener)) + , pool(pool) + , resolver(boost::asio::make_strand(ioc)) + , id(id) +{ + qCDebug(chatterinoWebsocket) << *this << "Created"; +} + +WebSocketConnection::~WebSocketConnection() +{ + assert(!this->listener && !this->pool); + qCDebug(chatterinoWebsocket) << *this << "Destroyed"; +} + +QDebug operator<<(QDebug dbg, const WebSocketConnection &conn) +{ + QDebugStateSaver state(dbg); + + dbg.noquote().nospace() + << '[' << conn.id << '|' << conn.options.url.toDisplayString() << ']'; + + return dbg; +} + +void WebSocketConnection::detach() +{ + if (this->listener) + { + this->listener->onClose(std::move(this->listener)); + } + if (this->pool) + { + this->pool->removeConnection(this); + this->pool = nullptr; + } + qCDebug(chatterinoWebsocket) << *this << "Detached"; +} + +} // namespace chatterino::ws::detail diff --git a/src/common/websockets/detail/WebSocketConnection.hpp b/src/common/websockets/detail/WebSocketConnection.hpp new file mode 100644 index 00000000..cb71961b --- /dev/null +++ b/src/common/websockets/detail/WebSocketConnection.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "common/websockets/WebSocketPool.hpp" +#include "util/QByteArrayBuffer.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +namespace chatterino::ws::detail { + +class WebSocketPoolImpl; + +/// A base class for a WebSocket connection. +/// +/// It's agnostic over the backing transport (TCP, TLS, proxy, etc.). +class WebSocketConnection +{ +public: + WebSocketConnection(WebSocketOptions options, int id, + std::unique_ptr listener, + WebSocketPoolImpl *pool, boost::asio::io_context &ioc); + virtual ~WebSocketConnection(); + + WebSocketConnection(const WebSocketConnection &) = delete; + WebSocketConnection(WebSocketConnection &&) = delete; + WebSocketConnection &operator=(const WebSocketConnection &) = delete; + WebSocketConnection &operator=(WebSocketConnection &&) = delete; + + /// Start connecting. + /// + /// Must be called from the desired executor. + virtual void run() = 0; + + /// Close this connection gracefully (if possible). + /// + /// Can be called from any thread. + virtual void close() = 0; + + /// Send or queue a text message. + /// + /// Can be called from any thread. + virtual void sendText(const QByteArray &data) = 0; + + /// Send or queue a binary message. + /// + /// Can be called from any thread. + virtual void sendBinary(const QByteArray &data) = 0; + +protected: + /// Reset and notify the parent and listener (if possible). + /// + /// - If the listener is set, notify it about a close event. + /// - If the parent is set, notify it about a closed connection. + /// - Set the listener and parent to (the equivalent of) nullptr. + void detach(); + + WebSocketOptions options; + // nullable, used for signalling a disconnect + std::unique_ptr listener; + // nullable, used for signalling a disconnect + WebSocketPoolImpl *pool; + + boost::asio::ip::tcp::resolver resolver; + + std::deque> queuedMessages; + bool isSending = false; + bool isClosing = false; + int id = 0; + + boost::beast::flat_buffer readBuffer; + + friend QDebug operator<<(QDebug dbg, const WebSocketConnection &conn); +}; + +} // namespace chatterino::ws::detail diff --git a/src/common/websockets/detail/WebSocketConnectionImpl.cpp b/src/common/websockets/detail/WebSocketConnectionImpl.cpp new file mode 100644 index 00000000..308a76f3 --- /dev/null +++ b/src/common/websockets/detail/WebSocketConnectionImpl.cpp @@ -0,0 +1,388 @@ +#include "common/websockets/detail/WebSocketConnectionImpl.hpp" + +#include "common/QLogging.hpp" +#include "common/Version.hpp" + +#include +#include +#include + +namespace chatterino::ws::detail { + +namespace asio = boost::asio; +namespace beast = boost::beast; + +// MARK: WebSocketConnectionHelper + +template +WebSocketConnectionHelper::WebSocketConnectionHelper( + WebSocketOptions options, int id, + std::unique_ptr listener, WebSocketPoolImpl *pool, + asio::io_context &ioc, Stream stream) + : WebSocketConnection(std::move(options), id, std::move(listener), pool, + ioc) + , stream(std::move(stream)) +{ +} + +template +void WebSocketConnectionHelper::post(auto &&fn) +{ + asio::post(this->stream.get_executor(), std::forward(fn)); +} + +template +void WebSocketConnectionHelper::run() +{ + auto host = this->options.url.host(QUrl::FullyEncoded).toStdString(); + if constexpr (requires { this->derived()->setupStream(host); }) + { + if (!this->derived()->setupStream(host)) + { + return; + } + } + + this->resolver.async_resolve( + host, std::to_string(this->options.url.port(Derived::DEFAULT_PORT)), + beast::bind_front_handler(&WebSocketConnectionHelper::onResolve, + this->shared_from_this())); +} + +template +void WebSocketConnectionHelper::close() +{ + this->post([self{this->shared_from_this()}] { + self->closeImpl(); + }); +} + +template +void WebSocketConnectionHelper::sendText(const QByteArray &data) +{ + this->post([self{this->shared_from_this()}, data] { + self->queuedMessages.emplace_back(true, data); + self->trySend(); + }); +} + +template +void WebSocketConnectionHelper::sendBinary( + const QByteArray &data) +{ + this->post([self{this->shared_from_this()}, data] { + self->queuedMessages.emplace_back(false, data); + self->trySend(); + }); +} + +template +Derived *WebSocketConnectionHelper::derived() +{ + return static_cast(this); +} + +template +void WebSocketConnectionHelper::onResolve( + boost::system::error_code ec, + const asio::ip::tcp::resolver::results_type &results) +{ + if (ec) + { + this->fail(ec, u"resolve"); + return; + } + + qCDebug(chatterinoWebsocket) << *this << "Resolved host"; + + beast::get_lowest_layer(this->stream) + .expires_after(std::chrono::seconds{30}); + beast::get_lowest_layer(this->stream) + .async_connect(results, beast::bind_front_handler( + &WebSocketConnectionHelper::onTcpHandshake, + this->shared_from_this())); +} + +template +void WebSocketConnectionHelper::onTcpHandshake( + boost::system::error_code ec, + const asio::ip::tcp::resolver::endpoint_type &ep) +{ + if (ec) + { + this->fail(ec, u"TCP handshake"); + return; + } + + qCDebug(chatterinoWebsocket) << *this << "TCP handshake done"; + this->options.url.setPort(ep.port()); + + this->derived()->afterTcpHandshake(); +} + +template +void WebSocketConnectionHelper::doWsHandshake() +{ + beast::get_lowest_layer(this->stream).expires_never(); + this->stream.set_option(beast::websocket::stream_base::timeout::suggested( + beast::role_type::client)); + this->stream.set_option(beast::websocket::stream_base::decorator{ + [this](beast::websocket::request_type &req) { + bool hasUa = false; + for (const auto &[key, value] : this->options.headers) + { + // TODO(Qt 6.5): Use QUtf8StringView + QLatin1StringView keyView(key.c_str()); + if (QLatin1StringView("user-agent") + .compare(keyView, Qt::CaseInsensitive) == 0) + { + hasUa = true; + } + + try + { + // this can fail if the key or value exceed the maximum size + req.set(key, value); + } + catch (const boost::system::system_error &err) + { + qCWarning(chatterinoWebsocket) + << "Invalid header - name:" << QUtf8StringView(key) + << "value:" << QUtf8StringView(value) + << "error:" << QUtf8StringView(err.what()); + } + } + + // default UA + if (!hasUa) + { + auto ua = QStringLiteral("Chatterino/%1 (%2)") + .arg(Version::instance().version(), + Version::instance().commitHash()) + .toStdString(); + req.set(beast::http::field::user_agent, ua); + } + }, + }); + + auto host = this->options.url.host(QUrl::FullyEncoded).toStdString() + ':' + + std::to_string(this->options.url.port(Derived::DEFAULT_PORT)); + auto path = this->options.url.path(QUrl::FullyEncoded).toStdString(); + if (path.empty()) + { + path = "/"; + } + this->stream.async_handshake( + host, path, + beast::bind_front_handler(&WebSocketConnectionHelper::onWsHandshake, + this->shared_from_this())); +} + +template +void WebSocketConnectionHelper::onWsHandshake( + boost::system::error_code ec) +{ + if (!this->listener || this->isClosing) + { + return; + } + if (ec) + { + this->fail(ec, u"WS handshake"); + return; + } + + qCDebug(chatterinoWebsocket) << *this << "WS handshake done"; + + this->trySend(); + this->stream.async_read( + this->readBuffer, + beast::bind_front_handler(&WebSocketConnectionHelper::onReadDone, + this->shared_from_this())); +} + +template +void WebSocketConnectionHelper::onReadDone( + boost::system::error_code ec, size_t bytesRead) +{ + if (!this->listener || this->isClosing) + { + return; + } + if (ec) + { + this->fail(ec, u"read"); + return; + } + + // XXX: this copies - we could read directly into a QByteArray + QByteArray data{ + static_cast(this->readBuffer.cdata().data()), + static_cast(bytesRead), + }; + this->readBuffer.consume(bytesRead); + + if (this->stream.got_text()) + { + this->listener->onTextMessage(std::move(data)); + } + else + { + this->listener->onBinaryMessage(std::move(data)); + } + + this->stream.async_read( + this->readBuffer, + beast::bind_front_handler(&WebSocketConnectionHelper::onReadDone, + this->shared_from_this())); +} + +template +void WebSocketConnectionHelper::onWriteDone( + boost::system::error_code ec, size_t /*bytesWritten*/) +{ + if (!this->queuedMessages.empty()) + { + this->queuedMessages.pop_front(); + } + else + { + assert(false); + } + this->isSending = false; + + if (ec) + { + this->fail(ec, u"write"); + return; + } + + this->trySend(); +} + +template +void WebSocketConnectionHelper::trySend() +{ + if (this->queuedMessages.empty() || this->isSending || + !this->stream.is_open()) + { + return; + } + + this->isSending = true; + this->stream.text(this->queuedMessages.front().first); + this->stream.async_write( + this->queuedMessages.front().second, + beast::bind_front_handler(&WebSocketConnectionHelper::onWriteDone, + this->shared_from_this())); +} + +template +void WebSocketConnectionHelper::closeImpl() +{ + if (this->isClosing) + { + return; + } + this->isClosing = true; + + qCDebug(chatterinoWebsocket) << *this << "Closing..."; + + // cancel all pending operations + this->resolver.cancel(); + beast::get_lowest_layer(this->stream).cancel(); + + this->stream.async_close( + beast::websocket::close_code::normal, + [this, lifetime{this->shared_from_this()}](auto ec) { + if (ec) + { + qCWarning(chatterinoWebsocket) << *this << "Failed to close" + << QUtf8StringView(ec.message()); + // make sure we cancel all operations + beast::get_lowest_layer(this->stream).close(); + } + else + { + qCDebug(chatterinoWebsocket) << *this << "Closed"; + } + this->detach(); + }); +} + +template +void WebSocketConnectionHelper::fail( + boost::system::error_code ec, QStringView op) +{ + qCWarning(chatterinoWebsocket) + << *this << "Failed:" << op << QUtf8StringView(ec.message()); + if (this->stream.is_open()) + { + this->closeImpl(); + } + this->detach(); +} + +// MARK: TlsWebSocketConnection + +TlsWebSocketConnection::TlsWebSocketConnection( + WebSocketOptions options, int id, + std::unique_ptr listener, WebSocketPoolImpl *pool, + asio::io_context &ioc, asio::ssl::context &ssl) + : WebSocketConnectionHelper(std::move(options), id, std::move(listener), + pool, ioc, Stream{asio::make_strand(ioc), ssl}) +{ +} + +bool TlsWebSocketConnection::setupStream(const std::string &host) +{ + // Set SNI Hostname (many hosts need this to handshake successfully) + if (::SSL_set_tlsext_host_name(this->stream.next_layer().native_handle(), + host.c_str()) == 0) + { + this->fail({static_cast(::ERR_get_error()), + asio::error::get_ssl_category()}, + u"Setting SNI hostname"); + return false; + } + return true; +} + +void TlsWebSocketConnection::afterTcpHandshake() +{ + beast::get_lowest_layer(this->stream) + .expires_after(std::chrono::seconds{30}); + this->stream.next_layer().async_handshake( + asio::ssl::stream_base::client, + [this, + lifetime{this->shared_from_this()}](boost::system::error_code ec) { + if (ec) + { + this->fail(ec, u"TLS handshake"); + return; + } + + qCDebug(chatterinoWebsocket) + << *this << "TLS handshake done, using" + << ::SSL_get_version(this->stream.next_layer().native_handle()); + this->doWsHandshake(); + }); +} + +// MARK: TcpWebSocketConnection + +TcpWebSocketConnection::TcpWebSocketConnection( + WebSocketOptions options, int id, + std::unique_ptr listener, WebSocketPoolImpl *pool, + asio::io_context &ioc) + : WebSocketConnectionHelper(std::move(options), id, std::move(listener), + pool, ioc, Stream{asio::make_strand(ioc)}) +{ +} + +void TcpWebSocketConnection::afterTcpHandshake() +{ + this->doWsHandshake(); +} + +} // namespace chatterino::ws::detail diff --git a/src/common/websockets/detail/WebSocketConnectionImpl.hpp b/src/common/websockets/detail/WebSocketConnectionImpl.hpp new file mode 100644 index 00000000..f9405ec3 --- /dev/null +++ b/src/common/websockets/detail/WebSocketConnectionImpl.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include "common/websockets/detail/WebSocketConnection.hpp" + +#include +#include +#include +#include + +namespace chatterino::ws::detail { + +/// A CRTP helper to share code between the TLS and TCP connections. +/// +/// `Derived` must have a `void afterTcpHandshake()` method, which is called if +/// the TCP handshake was successful. Subclasses can call `doWsHandshake` after +/// the intermediate handshake (i.e. TLS) is done. +/// +/// `Derived` must have a constant `DEFAULT_PORT` which specifies the TCP port +/// to connect to if the specified URL doesn't have one set. +/// +/// `Derived` can contain a method `bool setupStream(const std::string&)` which +/// is called from `run()`. The return value indicates if an error happened. An +/// implementation must've called `fail()` in case of errors. +template +class WebSocketConnectionHelper : public WebSocketConnection, + public std::enable_shared_from_this< + WebSocketConnectionHelper> +{ +public: + using Stream = boost::beast::websocket::stream; + + void post(auto &&fn); + + void run() final; + void close() final; + + void sendText(const QByteArray &data) final; + void sendBinary(const QByteArray &data) final; + +protected: + Derived *derived(); + + void fail(boost::system::error_code ec, QStringView op); + void doWsHandshake(); + + void closeImpl(); + void trySend(); + + Stream stream; + +private: + // This is private to ensure only `Derived` can construct this class. + WebSocketConnectionHelper(WebSocketOptions options, int id, + std::unique_ptr listener, + WebSocketPoolImpl *pool, + boost::asio::io_context &ioc, Stream stream); + + void onResolve(boost::system::error_code ec, + const boost::asio::ip::tcp::resolver::results_type &results); + void onTcpHandshake( + boost::system::error_code ec, + const boost::asio::ip::tcp::resolver::endpoint_type &ep); + void onWsHandshake(boost::system::error_code ec); + + void onReadDone(boost::system::error_code ec, size_t bytesRead); + void onWriteDone(boost::system::error_code ec, size_t bytesWritten); + + friend Derived; +}; + +/// A WebSocket connection over TLS (wss://). +class TlsWebSocketConnection + : public WebSocketConnectionHelper< + TlsWebSocketConnection, + boost::asio::ssl::stream> +{ +public: + static constexpr int DEFAULT_PORT = 443; + + TlsWebSocketConnection(WebSocketOptions options, int id, + std::unique_ptr listener, + WebSocketPoolImpl *pool, + boost::asio::io_context &ioc, + boost::asio::ssl::context &ssl); + +protected: + bool setupStream(const std::string &host); + void afterTcpHandshake(); + + friend WebSocketConnectionHelper< + TlsWebSocketConnection, + boost::asio::ssl::stream>; +}; + +/// A WebSocket connection over TCP (ws://). +class TcpWebSocketConnection + : public WebSocketConnectionHelper +{ +public: + static constexpr int DEFAULT_PORT = 80; + + TcpWebSocketConnection(WebSocketOptions options, int id, + std::unique_ptr listener, + WebSocketPoolImpl *pool, + boost::asio::io_context &ioc); + +protected: + void afterTcpHandshake(); + + friend WebSocketConnectionHelper; +}; + +} // namespace chatterino::ws::detail diff --git a/src/common/websockets/detail/WebSocketPoolImpl.cpp b/src/common/websockets/detail/WebSocketPoolImpl.cpp new file mode 100644 index 00000000..c1ed3090 --- /dev/null +++ b/src/common/websockets/detail/WebSocketPoolImpl.cpp @@ -0,0 +1,96 @@ +#include "common/websockets/detail/WebSocketPoolImpl.hpp" + +#include "Application.hpp" +#include "common/QLogging.hpp" +#include "common/websockets/detail/WebSocketConnection.hpp" +#include "util/RenameThread.hpp" + +#include + +namespace chatterino::ws::detail { + +WebSocketPoolImpl::WebSocketPoolImpl() + : ioc(1) + , ssl(boost::asio::ssl::context::tls_client) + , work(this->ioc.get_executor()) +{ + boost::system::error_code ec; + auto _ = this->ssl.set_options( + boost::asio::ssl::context::no_tlsv1 | + boost::asio::ssl::context::no_tlsv1_1 | + boost::asio::ssl::context::default_workarounds | + boost::asio::ssl::context::single_dh_use, + ec); + if (ec) + { + qCWarning(chatterinoWebsocket) << "Failed to set SSL context options" + << QString::fromStdString(ec.message()); + } + +#ifdef CHATTERINO_WITH_TESTS + if (!getApp()->isTest()) +#endif + { + this->ssl.set_verify_mode( + boost::asio::ssl::verify_peer | + boost::asio::ssl::verify_fail_if_no_peer_cert); + this->ssl.set_default_verify_paths(); + + boost::certify::enable_native_https_server_verification(this->ssl); + } + + this->ioThread = std::make_unique([this] { + this->ioc.run(); + this->shutdownFlag.set(); + }); + renameThread(*this->ioThread, "WebSocketPool"); +} + +WebSocketPoolImpl::~WebSocketPoolImpl() +{ + assert(this->closing); + // After 10s, we stop and, through std::thread::~thread, std::terminate will + // be called. + this->tryShutdown(std::chrono::seconds{10}); +} + +bool WebSocketPoolImpl::tryShutdown(std::chrono::milliseconds timeout) +{ + this->closing = true; + if (!this->ioThread || !this->ioThread->joinable()) + { + return true; + } + + this->work.reset(); + { + std::lock_guard g(this->connectionMutex); + for (const auto &conn : this->connections) + { + conn->close(); + } + } + + if (!this->shutdownFlag.waitFor(timeout)) + { + qCWarning(chatterinoWebsocket) + << "Failed to gracefully close all connections in time"; + return false; + } + + if (this->ioThread->joinable()) + { + this->ioThread->join(); + } + return true; +} + +void WebSocketPoolImpl::removeConnection(WebSocketConnection *conn) +{ + std::lock_guard g(this->connectionMutex); + std::erase_if(this->connections, [conn](const auto &v) { + return v.get() == conn; + }); +} + +} // namespace chatterino::ws::detail diff --git a/src/common/websockets/detail/WebSocketPoolImpl.hpp b/src/common/websockets/detail/WebSocketPoolImpl.hpp new file mode 100644 index 00000000..3d99346b --- /dev/null +++ b/src/common/websockets/detail/WebSocketPoolImpl.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include "util/OnceFlag.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace chatterino::ws::detail { + +class WebSocketConnection; + +class WebSocketPoolImpl +{ +public: + WebSocketPoolImpl(); + ~WebSocketPoolImpl(); + + WebSocketPoolImpl(const WebSocketPoolImpl &) = delete; + WebSocketPoolImpl(WebSocketPoolImpl &&) = delete; + WebSocketPoolImpl &operator=(const WebSocketPoolImpl &) = delete; + WebSocketPoolImpl &operator=(WebSocketPoolImpl &&) = delete; + + void removeConnection(WebSocketConnection *conn); + + /// Attempts to shut down all connections by gracefully closing them. + /// + /// If the connections don't close within `timeout`, `false` is returned and + /// this pool should be leaked. + bool tryShutdown(std::chrono::milliseconds timeout); + + std::unique_ptr ioThread; + boost::asio::io_context ioc; + boost::asio::ssl::context ssl; + boost::asio::executor_work_guard + work; + + std::vector> connections; + std::mutex connectionMutex; + + bool closing = false; + int nextID = 1; + + OnceFlag shutdownFlag; +}; + +} // namespace chatterino::ws::detail diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp index e1e59a94..b8c382b5 100644 --- a/src/controllers/plugins/LuaAPI.hpp +++ b/src/controllers/plugins/LuaAPI.hpp @@ -85,6 +85,7 @@ sol::table toTable(lua_State *L, const CompletionEvent &ev); * @includefile controllers/plugins/api/ChannelRef.hpp * @includefile controllers/plugins/api/HTTPResponse.hpp * @includefile controllers/plugins/api/HTTPRequest.hpp + * @includefile controllers/plugins/api/WebSocket.hpp * @includefile common/network/NetworkCommon.hpp */ diff --git a/src/controllers/plugins/Plugin.cpp b/src/controllers/plugins/Plugin.cpp index 739fa637..2d747095 100644 --- a/src/controllers/plugins/Plugin.cpp +++ b/src/controllers/plugins/Plugin.cpp @@ -293,5 +293,12 @@ bool Plugin::hasHTTPPermissionFor(const QUrl &url) }); } +bool Plugin::hasNetworkPermission() const +{ + return std::ranges::any_of(this->meta.permissions, [](const auto &p) { + return p.type == PluginPermission::Type::Network; + }); +} + } // namespace chatterino #endif diff --git a/src/controllers/plugins/Plugin.hpp b/src/controllers/plugins/Plugin.hpp index a6532946..ddd74195 100644 --- a/src/controllers/plugins/Plugin.hpp +++ b/src/controllers/plugins/Plugin.hpp @@ -136,6 +136,7 @@ public: bool hasFSPermissionFor(bool write, const QString &path); bool hasHTTPPermissionFor(const QUrl &url); + bool hasNetworkPermission() const; std::map callbacks; diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index d2231394..06ce6c32 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -11,6 +11,7 @@ # include "controllers/plugins/api/HTTPRequest.hpp" # include "controllers/plugins/api/HTTPResponse.hpp" # include "controllers/plugins/api/IOWrapper.hpp" +# include "controllers/plugins/api/WebSocket.hpp" # include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" # include "controllers/plugins/SolTypes.hpp" @@ -225,6 +226,11 @@ void PluginController::initSol(sol::state_view &lua, Plugin *plugin) c2["EventType"] = lua::createEnumTable(lua); c2["LogLevel"] = lua::createEnumTable(lua); + if (plugin->hasNetworkPermission()) + { + lua::api::WebSocket::createUserType(c2, plugin); + } + sol::table io = g["io"]; io.set_function( "open", sol::overload(&lua::api::io_open, &lua::api::io_open_modeless)); @@ -428,5 +434,10 @@ std::pair PluginController::updateCustomCompletions( return {false, results}; } +WebSocketPool &PluginController::webSocketPool() +{ + return this->webSocketPool_; +} + } // namespace chatterino #endif diff --git a/src/controllers/plugins/PluginController.hpp b/src/controllers/plugins/PluginController.hpp index ce4fe617..4c3a9aae 100644 --- a/src/controllers/plugins/PluginController.hpp +++ b/src/controllers/plugins/PluginController.hpp @@ -2,6 +2,7 @@ #ifdef CHATTERINO_HAVE_PLUGINS +# include "common/websockets/WebSocketPool.hpp" # include "controllers/commands/CommandContext.hpp" # include "controllers/plugins/Plugin.hpp" @@ -61,6 +62,8 @@ public: const QString &query, const QString &fullTextContent, int cursorPosition, bool isFirstWord) const; + WebSocketPool &webSocketPool(); + private: void loadPlugins(); void load(const QFileInfo &index, const QDir &pluginDir, @@ -74,6 +77,7 @@ private: static void loadChatterinoLib(lua_State *l); bool tryLoadFromDir(const QDir &pluginDir); std::map> plugins_; + WebSocketPool webSocketPool_; // This is for tests, pay no attention friend class PluginControllerAccess; diff --git a/src/controllers/plugins/SolTypes.cpp b/src/controllers/plugins/SolTypes.cpp index 43de0829..e47cd93e 100644 --- a/src/controllers/plugins/SolTypes.cpp +++ b/src/controllers/plugins/SolTypes.cpp @@ -1,10 +1,12 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "controllers/plugins/SolTypes.hpp" +# include "common/QLogging.hpp" # include "controllers/plugins/PluginController.hpp" # include # include + namespace chatterino::lua { Plugin *ThisPluginState::plugin() @@ -22,6 +24,13 @@ Plugin *ThisPluginState::plugin() return pl; } +void logError(Plugin *plugin, QStringView context, const QString &msg) +{ + qCWarning(chatterinoLua).noquote() + << "[" + plugin->id + ":" + plugin->meta.name + "]" << context << "-" + << msg; +} + } // namespace chatterino::lua // NOLINTBEGIN(readability-named-parameter) @@ -89,7 +98,7 @@ QByteArray sol_lua_get(sol::types, lua_State *L, int index, sol::stack::record &tracking) { auto str = sol::stack::get(L, index, tracking); - return QByteArray::fromRawData(str.data(), str.length()); + return {str.data(), static_cast(str.length())}; } int sol_lua_push(sol::types, lua_State *L, const QByteArray &value) diff --git a/src/controllers/plugins/SolTypes.hpp b/src/controllers/plugins/SolTypes.hpp index 3ee41c3a..fdd6bedb 100644 --- a/src/controllers/plugins/SolTypes.hpp +++ b/src/controllers/plugins/SolTypes.hpp @@ -67,8 +67,12 @@ private: /// `std::optional` means nil|LuaEquiv (or zero returns) /// A return type that doesn't match returns an error template -inline nonstd::expected_lite::expected tryCall( - const sol::protected_function &function, Args &&...args) +inline nonstd::expected_lite::expected tryCall(const auto &function, + Args &&...args) + requires(std::same_as, + sol::protected_function> || + std::same_as, + sol::main_protected_function>) { sol::protected_function_result result = function(std::forward(args)...); @@ -148,6 +152,27 @@ inline nonstd::expected_lite::expected tryCall( } } +void logError(Plugin *plugin, QStringView context, const QString &msg); + +template +bool hasValueOrLog(const nonstd::expected &res, QStringView context, + Plugin *plugin) +{ + if (!res.has_value()) + { + logError(plugin, context, res.error()); + return false; + } + return true; +} + +void loggedVoidCall(const auto &fn, QStringView context, Plugin *plugin, + auto &&...args) +{ + auto res = tryCall(fn, std::forward(args)...); + hasValueOrLog(res, context, plugin); +} + // NOLINTNEXTLINE(cppcoreguidelines-macro-usage) # define SOL_STACK_FUNCTIONS(TYPE) \ bool sol_lua_check(sol::types, lua_State *L, int index, \ diff --git a/src/controllers/plugins/api/WebSocket.cpp b/src/controllers/plugins/api/WebSocket.cpp new file mode 100644 index 00000000..c2850602 --- /dev/null +++ b/src/controllers/plugins/api/WebSocket.cpp @@ -0,0 +1,175 @@ +#include "controllers/plugins/api/WebSocket.hpp" + +#ifdef CHATTERINO_HAVE_PLUGINS +# include "Application.hpp" +# include "controllers/plugins/PluginController.hpp" +# include "controllers/plugins/SolTypes.hpp" // IWYU pragma: keep +# include "util/PostToThread.hpp" + +# include + +namespace chatterino::lua::api { + +/// A WebSocket listener that dispatches events on the GUI thread to the user type. +class WebSocketListenerProxy final : public WebSocketListener +{ +public: + WebSocketListenerProxy(std::weak_ptr target); + + void onClose(std::unique_ptr self) override; + void onBinaryMessage(QByteArray data) override; + void onTextMessage(QByteArray data) override; + +private: + std::weak_ptr target; +}; + +WebSocket::WebSocket() = default; + +void WebSocket::createUserType(sol::table &c2, Plugin *plugin) +{ + c2.new_usertype( + "WebSocket", + sol::factories([plugin](const QString &spec, sol::variadic_args args) { + QUrl url(spec); + if (url.scheme() != "wss" && url.scheme() != "ws") + { + throw std::runtime_error("Scheme must be wss:// or ws://"); + } + + auto self = std::make_shared(); + self->plugin = plugin; + + WebSocketOptions opts{.url = url, .headers = {}}; + if (args.size() >= 1) + { + sol::table luaOpts = args[0]; + sol::optional headers = luaOpts["headers"]; + if (headers) + { + for (const auto &[k, v] : *headers) + { + opts.headers.emplace_back(k.as(), + v.as()); + } + } + sol::optional onText = luaOpts["on_text"]; + sol::optional onBinary = + luaOpts["on_binary"]; + sol::optional onClose = luaOpts["on_close"]; + if (onText) + { + self->onText = std::move(*onText); + } + if (onBinary) + { + self->onBinary = std::move(*onBinary); + } + if (onClose) + { + self->onClose = std::move(*onClose); + } + } + + auto handle = getApp()->getPlugins()->webSocketPool().createSocket( + opts, std::make_unique(self)); + + self->handle = std::move(handle); + return self; + }), + // Note: These properties could be pointers to members, but Clang 18 + // specifically can't compile these - see https://github.com/ThePhD/sol2/issues/1581 + "on_close", + sol::property( + [](WebSocket &ws) { + return ws.onClose; + }, + [](WebSocket &ws, sol::main_function fn) { + ws.onClose = std::move(fn); + }), + "on_text", + sol::property( + [](WebSocket &ws) { + return ws.onText; + }, + [](WebSocket &ws, sol::main_function fn) { + ws.onText = std::move(fn); + }), + "on_binary", + sol::property( + [](WebSocket &ws) { + return ws.onBinary; + }, + [](WebSocket &ws, sol::main_function fn) { + ws.onBinary = std::move(fn); + }), + "close", &WebSocket::close, // + "send_text", &WebSocket::sendText, // + "send_binary", &WebSocket::sendBinary // + ); +} + +void WebSocket::close() +{ + this->handle.close(); +} + +void WebSocket::sendText(const QByteArray &data) +{ + this->handle.sendText(data); +} + +void WebSocket::sendBinary(const QByteArray &data) +{ + this->handle.sendBinary(data); +} + +WebSocketListenerProxy::WebSocketListenerProxy(std::weak_ptr target) + : target(std::move(target)) +{ +} + +void WebSocketListenerProxy::onClose(std::unique_ptr self) +{ + runInGuiThread([this, lifetime{std::move(self)}] { + auto strong = this->target.lock(); + if (strong) + { + // clear our object, so we can get GC'd + auto cb = std::move(strong->onClose); + strong->onText.reset(); + strong->onBinary.reset(); + loggedVoidCall(cb, u"WebSocket.on_close", strong->plugin); + } + }); +} + +void WebSocketListenerProxy::onTextMessage(QByteArray data) +{ + auto target = this->target; + runInGuiThread([target, data{std::move(data)}] { + auto strong = target.lock(); + if (strong) + { + loggedVoidCall(strong->onText, u"WebSocket.on_text", strong->plugin, + data); + } + }); +} + +void WebSocketListenerProxy::onBinaryMessage(QByteArray data) +{ + auto target = this->target; + runInGuiThread([target, data{std::move(data)}] { + auto strong = target.lock(); + if (strong) + { + loggedVoidCall(strong->onBinary, u"WebSocket.on_binary", + strong->plugin, data); + } + }); +} + +} // namespace chatterino::lua::api + +#endif diff --git a/src/controllers/plugins/api/WebSocket.hpp b/src/controllers/plugins/api/WebSocket.hpp new file mode 100644 index 00000000..21730381 --- /dev/null +++ b/src/controllers/plugins/api/WebSocket.hpp @@ -0,0 +1,78 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS + +# include "common/websockets/WebSocketPool.hpp" + +# include +# include + +namespace chatterino { +class Plugin; +} // namespace chatterino + +namespace chatterino::lua::api { + +/** + * @lua@class c2.WebSocket + */ +class WebSocket +{ +public: + /** + * Creates and connects to a WebSocket server. Upon calling this, a + * connection is made immediately. + * + * @lua@param url string The URL to connect to. Must start with `wss://` or `ws://`. + * @lua@param options? { headers?: table, on_close?: fun(), on_text?: fun(data: string), on_binary?: fun(data: string) } Additional options for the connection. + * @lua@return c2.WebSocket + * @lua@nodiscard + * @exposed c2.WebSocket.new + */ + WebSocket(); + + static void createUserType(sol::table &c2, Plugin *plugin); + + /** + * Closes the socket. + * + * @exposed c2.WebSocket:close + */ + void close(); + /** + * Sends a text message on the socket. + * + * @lua@param data string The text to send. + * @exposed c2.WebSocket:send_text + */ + void sendText(const QByteArray &data); + /** + * Sends a binary message on the socket. + * + * @lua@param data string The binary data to send. + * @exposed c2.WebSocket:send_binary + */ + void sendBinary(const QByteArray &data); + +private: + /** + * @lua@field on_close fun()|nil Handler called when the socket is closed. + */ + sol::main_function onClose; + /** + * @lua@field on_text fun(data: string)|nil Handler called when the socket receives a text message. + */ + sol::main_function onText; + /** + * @lua@field on_binary fun(data: string)|nil Handler called when the socket receives a binary message. + */ + sol::main_function onBinary; + WebSocketHandle handle; + // Note: this class lives inside the plugin -> this pointer will be valid. + Plugin *plugin = nullptr; + + friend class WebSocketListenerProxy; +}; + +} // namespace chatterino::lua::api + +#endif diff --git a/src/util/PostToThread.hpp b/src/util/PostToThread.hpp index 0401091b..8021defa 100644 --- a/src/util/PostToThread.hpp +++ b/src/util/PostToThread.hpp @@ -9,32 +9,31 @@ namespace chatterino { // Taken from // https://stackoverflow.com/questions/21646467/how-to-execute-a-functor-or-a-lambda-in-a-given-thread-in-qt-gcd-style // Qt 5/4 - preferred, has least allocations -template -static void postToThread(F &&fun, QObject *obj = QCoreApplication::instance()) +static void postToThread(auto &&f, QObject *obj = QCoreApplication::instance()) { struct Event : public QEvent { - using Fun = typename std::decay::type; + using Fun = typename std::decay_t; Fun fun; - Event(Fun &&fun) + + Event(decltype(fun) f) : QEvent(QEvent::None) - , fun(std::move(fun)) - { - } - Event(const Fun &fun) - : QEvent(QEvent::None) - , fun(fun) + , fun(std::forward(f)) { } + Event(const Event &) = delete; + Event(Event &&) = delete; + Event &operator=(const Event &) = delete; + Event &operator=(Event &&) = delete; + ~Event() override { fun(); } }; - QCoreApplication::postEvent(obj, new Event(std::forward(fun))); + QCoreApplication::postEvent(obj, new Event(std::forward(f))); } -template -static void runInGuiThread(F &&fun) +static void runInGuiThread(auto &&fun) { if (isGuiThread()) { @@ -42,17 +41,16 @@ static void runInGuiThread(F &&fun) } else { - postToThread(fun); + postToThread(std::forward(fun)); } } -template -inline void postToGuiThread(F &&fun) +inline void postToGuiThread(auto &&fun) { assert(!isGuiThread() && "postToGuiThread must be called from a non-GUI thread"); - postToThread(std::forward(fun)); + postToThread(std::forward(fun)); } } // namespace chatterino diff --git a/src/util/QByteArrayBuffer.hpp b/src/util/QByteArrayBuffer.hpp new file mode 100644 index 00000000..b16ca1ef --- /dev/null +++ b/src/util/QByteArrayBuffer.hpp @@ -0,0 +1,120 @@ +#pragma once + +#include +#include + +namespace chatterino { + +/// A ConstBufferSequence over a single QByteArray. +/// https://www.boost.org/doc/libs/1_87_0/doc/html/boost_asio/reference/ConstBufferSequence.html +struct QByteArrayBuffer { + struct QByteArrayHolder { + QByteArray data; + + operator boost::asio::const_buffer() const + { + return { + this->data.constData(), + static_cast(this->data.size()), + }; + } + }; + + struct ConstIterator : public std::bidirectional_iterator_tag { + // using iterator_category = std::bidirectional_iterator_tag; + using value_type = QByteArrayHolder; + using difference_type = ptrdiff_t; + using pointer = const QByteArrayHolder *; + using reference = const QByteArrayHolder &; + + constexpr ConstIterator() noexcept = default; + + constexpr explicit ConstIterator(pointer ptr) noexcept + : ptr(ptr) + { + } + + [[nodiscard]] constexpr reference operator*() const noexcept + { + return *ptr; + } + + [[nodiscard]] constexpr pointer operator->() const noexcept + { + return ptr; + } + + constexpr ConstIterator &operator++() noexcept + { + ++ptr; + return *this; + } + + constexpr ConstIterator operator++(int) noexcept + { + ConstIterator tmp = *this; + ++ptr; + return tmp; + } + + constexpr ConstIterator &operator--() noexcept + { + --ptr; + return *this; + } + + constexpr ConstIterator operator--(int) noexcept + { + ConstIterator tmp = *this; + --ptr; + return tmp; + } + + [[nodiscard]] constexpr auto operator==( + const ConstIterator &rhs) const noexcept + { + return this->ptr == rhs.ptr; + } + + const QByteArrayHolder *ptr = nullptr; + }; + + using value_type = QByteArrayHolder; + using size_type = size_t; + using difference_type = ptrdiff_t; + using pointer = QByteArrayHolder *; + using const_pointer = const QByteArrayHolder *; + using reference = QByteArrayHolder &; + using const_reference = const QByteArrayHolder &; + using iterator = ConstIterator; + using const_iterator = ConstIterator; + + QByteArrayBuffer(QByteArray ba) + : holder({std::move(ba)}) + { + } + + const_iterator begin() const + { + return ConstIterator(&this->holder); + } + + const_iterator end() const + { + return ConstIterator((&this->holder) + 1); + } + + QByteArray data() const + { + return this->holder.data; + } + + QByteArrayHolder holder; +}; + +static_assert(sizeof(QByteArrayBuffer) == sizeof(QByteArray)); + +} // namespace chatterino + +static_assert(boost::beast::is_const_buffer_sequence< + chatterino::QByteArrayBuffer>::value); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6d2cb267..cc75c642 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -54,6 +54,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/OnceFlag.cpp ${CMAKE_CURRENT_LIST_DIR}/src/IncognitoBrowser.cpp ${CMAKE_CURRENT_LIST_DIR}/src/EventSubMessages.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/WebSocketPool.cpp ${CMAKE_CURRENT_LIST_DIR}/src/NativeMessaging.cpp ${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.cpp diff --git a/tests/src/NetworkHelpers.hpp b/tests/src/NetworkHelpers.hpp index 7125deb7..eaabd4bb 100644 --- a/tests/src/NetworkHelpers.hpp +++ b/tests/src/NetworkHelpers.hpp @@ -35,6 +35,7 @@ public: void waitForRequest() { using namespace std::chrono_literals; + auto start = std::chrono::system_clock::now(); while (true) { @@ -50,6 +51,11 @@ public: } QCoreApplication::processEvents(QEventLoop::AllEvents); QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + + if (std::chrono::system_clock::now() - start > 2min) + { + throw std::runtime_error("Timeout"); + } } ASSERT_TRUE(this->requestDone_); diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp index 588d3c2f..acc0ef1c 100644 --- a/tests/src/Plugins.cpp +++ b/tests/src/Plugins.cpp @@ -5,6 +5,7 @@ # include "controllers/commands/Command.hpp" // IWYU pragma: keep # include "controllers/commands/CommandController.hpp" # include "controllers/plugins/api/ChannelRef.hpp" +# include "controllers/plugins/api/WebSocket.hpp" # include "controllers/plugins/Plugin.hpp" # include "controllers/plugins/PluginController.hpp" # include "controllers/plugins/PluginPermission.hpp" @@ -638,4 +639,176 @@ TEST_F(PluginTest, tryCallTest) } } +TEST_F(PluginTest, testTcpWebSocket) +{ + configure({PluginPermission{{{"type", "Network"}}}}); + + RequestWaiter waiter; + std::vector> messages; + lua->set("done", [&] { + waiter.requestDone(); + }); + lua->set("add", [&](bool isText, QByteArray data) { + messages.emplace_back(isText, std::move(data)); + }); + + std::shared_ptr ws = lua->script(R"lua( + local ws = c2.WebSocket.new("ws://127.0.0.1:9052/echo") + ws.on_text = function(data) + add(true, data) + end + local any_msg = false + ws.on_binary = function(data) + if not any_msg then + any_msg = true + ws:send_text(string.rep("a", 1 << 15)) + ws:send_binary("wow") + ws:send_text("/HEADER user-agent") + ws:send_binary("/CLOSE") + end + add(false, data) + end + ws.on_close = function() + done() + end + ws:send_text("message1") + ws:send_text("message2") + ws:send_text("message3") + ws:send_binary("message4") + + return ws + )lua"); + std::weak_ptr weakWs{ws}; + ws.reset(); + + waiter.waitForRequest(); + + ASSERT_EQ(messages.size(), 7); + ASSERT_EQ(messages[0].first, true); + ASSERT_EQ(messages[0].second, "message1"); + ASSERT_EQ(messages[1].first, true); + ASSERT_EQ(messages[1].second, "message2"); + ASSERT_EQ(messages[2].first, true); + ASSERT_EQ(messages[2].second, "message3"); + ASSERT_EQ(messages[3].first, false); + ASSERT_EQ(messages[3].second, "message4"); + ASSERT_EQ(messages[4].first, true); + ASSERT_EQ(messages[4].second, QByteArray(1 << 15, 'a')); + ASSERT_EQ(messages[5].first, false); + ASSERT_EQ(messages[5].second, "wow"); + ASSERT_EQ(messages[6].first, true); + ASSERT_TRUE(messages[6].second.startsWith("Chatterino")); + + ASSERT_FALSE(weakWs.expired()); + lua->collect_garbage(); + ASSERT_TRUE(weakWs.expired()); +} + +TEST_F(PluginTest, testTlsWebSocket) +{ + configure({PluginPermission{{{"type", "Network"}}}}); + + RequestWaiter waiter; + std::vector> messages; + lua->set("done", [&] { + waiter.requestDone(); + }); + lua->set("add", [&](bool isText, QByteArray data) { + messages.emplace_back(isText, std::move(data)); + }); + + std::shared_ptr ws = lua->script(R"lua( + local ws = c2.WebSocket.new("wss://127.0.0.1:9050/echo", { + headers = { + ["User-Agent"] = "Lua", + ["A-Header"] = "A value", + ["Referer"] = "https://chatterino.com", + }, + }) + ws.on_text = function(data) + add(true, data) + end + local any_msg = false + ws.on_binary = function(data) + if not any_msg then + any_msg = true + ws:send_text(string.rep("a", 1 << 15)) + ws:send_binary("wow") + ws:send_text("/HEADER user-agent") + ws:send_text("/HEADER a-header") + ws:send_text("/HEADER referer") + ws:send_binary("/CLOSE") + end + add(false, data) + end + ws.on_close = function() + done() + end + ws:send_text("message1") + ws:send_text("message2") + ws:send_text("message3") + ws:send_binary("message4") + + return ws + )lua"); + std::weak_ptr weakWs{ws}; + ws.reset(); + + waiter.waitForRequest(); + + ASSERT_EQ(messages.size(), 9); + ASSERT_EQ(messages[0].first, true); + ASSERT_EQ(messages[0].second, "message1"); + ASSERT_EQ(messages[1].first, true); + ASSERT_EQ(messages[1].second, "message2"); + ASSERT_EQ(messages[2].first, true); + ASSERT_EQ(messages[2].second, "message3"); + ASSERT_EQ(messages[3].first, false); + ASSERT_EQ(messages[3].second, "message4"); + ASSERT_EQ(messages[4].first, true); + ASSERT_EQ(messages[4].second, QByteArray(1 << 15, 'a')); + ASSERT_EQ(messages[5].first, false); + ASSERT_EQ(messages[5].second, "wow"); + ASSERT_EQ(messages[6].first, true); + ASSERT_EQ(messages[6].second, "Lua"); + ASSERT_EQ(messages[7].first, true); + ASSERT_EQ(messages[7].second, "A value"); + ASSERT_EQ(messages[8].first, true); + ASSERT_EQ(messages[8].second, "https://chatterino.com"); + + ASSERT_FALSE(weakWs.expired()); + lua->collect_garbage(); + ASSERT_TRUE(weakWs.expired()); +} + +TEST_F(PluginTest, testWebSocketNoPerms) +{ + configure(); + + bool res = lua->script(R"lua( + return c2["WebSocket"] == nil + )lua"); + ASSERT_TRUE(res); +} + +TEST_F(PluginTest, testWebSocketApi) +{ + configure({PluginPermission{{{"type", "Network"}}}}); + + bool ok = lua->script(R"lua( + local t = function () end + local b = function () end + local c = function () end + local ws = c2.WebSocket.new("wss://127.0.0.1:9050/echo", { + on_text = t, + on_binary = b, + on_close = c, + }) + + return ws.on_text == t and ws.on_binary == b and ws.on_close == c + )lua"); + + ASSERT_TRUE(ok); +} + #endif diff --git a/tests/src/WebSocketPool.cpp b/tests/src/WebSocketPool.cpp new file mode 100644 index 00000000..4851049e --- /dev/null +++ b/tests/src/WebSocketPool.cpp @@ -0,0 +1,163 @@ +#include "common/websockets/WebSocketPool.hpp" + +#include "mocks/BaseApplication.hpp" +#include "Test.hpp" +#include "util/OnceFlag.hpp" + +using namespace chatterino; +using namespace std::chrono_literals; + +namespace { + +struct Listener : public WebSocketListener { + Listener(std::vector> &messages, + OnceFlag &messageFlag, OnceFlag &closeFlag) + : messages(messages) + , messageFlag(messageFlag) + , closeFlag(closeFlag) + { + } + + void onClose(std::unique_ptr /*self*/) override + { + this->closeFlag.set(); + } + + void onTextMessage(QByteArray data) override + { + this->messageFlag.set(); + messages.emplace_back(true, std::move(data)); + } + + void onBinaryMessage(QByteArray data) override + { + // no flag to know when the initial queue is empty + messages.emplace_back(false, std::move(data)); + } + + std::vector> &messages; + OnceFlag &messageFlag; + OnceFlag &closeFlag; +}; + +} // namespace + +TEST(WebSocketPool, tcpEcho) +{ + mock::BaseApplication app; + WebSocketPool pool; + + std::vector> messages; + OnceFlag messageFlag; + OnceFlag closeFlag; + + auto handle = pool.createSocket( + { + .url = QUrl("ws://127.0.0.1:9052/echo"), + .headers = + { + {"My-Header", "my-header-VALUE"}, + {"Another-Header", "other-header"}, + {"Cookie", "xd"}, // "known" header + {"User-Agent", "MyUserAgent"}, + }, + }, + std::make_unique(messages, messageFlag, closeFlag)); + handle.sendBinary("message1"); + handle.sendBinary("message2"); + handle.sendBinary("message3"); + handle.sendText("message4"); + + ASSERT_TRUE(messageFlag.waitFor(1s)); + QByteArray bigMsg(1 << 15, 'A'); + handle.sendBinary(bigMsg); + handle.sendText("foo"); + handle.sendText("/HEADER my-header"); + handle.sendText("/HEADER another-header"); + handle.sendText("/HEADER cookie"); + handle.sendText("/HEADER user-agent"); + handle.sendText("/CLOSE"); + + ASSERT_TRUE(closeFlag.waitFor(1s)); + + ASSERT_EQ(messages.size(), 10); + ASSERT_EQ(messages[0].first, false); + ASSERT_EQ(messages[0].second, "message1"); + ASSERT_EQ(messages[1].first, false); + ASSERT_EQ(messages[1].second, "message2"); + ASSERT_EQ(messages[2].first, false); + ASSERT_EQ(messages[2].second, "message3"); + ASSERT_EQ(messages[3].first, true); + ASSERT_EQ(messages[3].second, "message4"); + ASSERT_EQ(messages[4].first, false); + ASSERT_EQ(messages[4].second, bigMsg); + ASSERT_EQ(messages[5].first, true); + ASSERT_EQ(messages[5].second, "foo"); + ASSERT_EQ(messages[6].first, true); + ASSERT_EQ(messages[6].second, "my-header-VALUE"); + ASSERT_EQ(messages[7].first, true); + ASSERT_EQ(messages[7].second, "other-header"); + ASSERT_EQ(messages[8].first, true); + ASSERT_EQ(messages[8].second, "xd"); + ASSERT_EQ(messages[9].first, true); + ASSERT_EQ(messages[9].second, "MyUserAgent"); +} + +TEST(WebSocketPool, tlsEcho) +{ + mock::BaseApplication app; + WebSocketPool pool; + + std::vector> messages; + OnceFlag messageFlag; + OnceFlag closeFlag; + + auto handle = pool.createSocket( + { + .url = QUrl("wss://127.0.0.1:9050/echo"), + .headers{ + {"My-Header", "my-header-VALUE"}, + {"Another-Header", "other-header"}, + {"Cookie", "xd"}, // "known" header + }, + }, + std::make_unique(messages, messageFlag, closeFlag)); + handle.sendBinary("message1"); + handle.sendBinary("message2"); + handle.sendBinary("message3"); + handle.sendText("message4"); + + ASSERT_TRUE(messageFlag.waitFor(1s)); + QByteArray bigMsg(1 << 15, 'A'); + handle.sendBinary(bigMsg); + handle.sendText("foo"); + handle.sendText("/HEADER my-header"); + handle.sendText("/HEADER another-header"); + handle.sendText("/HEADER cookie"); + handle.sendText("/HEADER user-agent"); + handle.sendText("/CLOSE"); + + ASSERT_TRUE(closeFlag.waitFor(1s)); + + ASSERT_EQ(messages.size(), 10); + ASSERT_EQ(messages[0].first, false); + ASSERT_EQ(messages[0].second, "message1"); + ASSERT_EQ(messages[1].first, false); + ASSERT_EQ(messages[1].second, "message2"); + ASSERT_EQ(messages[2].first, false); + ASSERT_EQ(messages[2].second, "message3"); + ASSERT_EQ(messages[3].first, true); + ASSERT_EQ(messages[3].second, "message4"); + ASSERT_EQ(messages[4].first, false); + ASSERT_EQ(messages[4].second, bigMsg); + ASSERT_EQ(messages[5].first, true); + ASSERT_EQ(messages[5].second, "foo"); + ASSERT_EQ(messages[6].first, true); + ASSERT_EQ(messages[6].second, "my-header-VALUE"); + ASSERT_EQ(messages[7].first, true); + ASSERT_EQ(messages[7].second, "other-header"); + ASSERT_EQ(messages[8].first, true); + ASSERT_EQ(messages[8].second, "xd"); + ASSERT_EQ(messages[9].first, true); + ASSERT_TRUE(messages[9].second.startsWith("Chatterino")); +}