feat(plugins): add c2.WebSocket (#6076)

This commit is contained in:
nerix
2025-04-07 19:38:10 +02:00
committed by GitHub
parent d3bab9132f
commit ab66be21b3
31 changed files with 1859 additions and 29 deletions
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+1
View File
@@ -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)
+23 -1
View File
@@ -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<T>(type: T, func: CbFunc<T>): 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<string, string>;
on_close?: () => void;
on_text?: (data: string) => void;
on_binary?: (data: string) => void;
}
) => WebSocket;
}
var WebSocket: WebSocketConstructor;
}
+33
View File
@@ -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<string, string>, 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
+11
View File
@@ -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
+125
View File
@@ -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<WebSocketListener> listener)
{
if (!this->impl)
{
try
{
this->impl = std::make_unique<ws::detail::WebSocketPoolImpl>();
}
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<ws::detail::WebSocketConnection> conn;
if (options.url.scheme() == "wss")
{
conn = std::make_shared<ws::detail::TlsWebSocketConnection>(
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<ws::detail::TcpWebSocketConnection>(
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<ws::detail::WebSocketConnection> 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
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <QByteArray>
#include <QUrl>
#include <memory>
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<ws::detail::WebSocketConnection> 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<ws::detail::WebSocketConnection> 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<WebSocketListener> self) = 0;
};
struct WebSocketOptions {
QUrl url;
std::vector<std::pair<std::string, std::string>> headers;
};
class WebSocketPool
{
public:
WebSocketPool();
~WebSocketPool();
[[nodiscard]] WebSocketHandle createSocket(
WebSocketOptions options, std::unique_ptr<WebSocketListener> listener);
private:
std::unique_ptr<ws::detail::WebSocketPoolImpl> impl;
};
} // namespace chatterino
@@ -0,0 +1,53 @@
#include "common/websockets/detail/WebSocketConnection.hpp"
#include "common/QLogging.hpp"
#include "WebSocketPoolImpl.hpp"
#include <boost/asio/strand.hpp>
namespace chatterino::ws::detail {
WebSocketConnection::WebSocketConnection(
WebSocketOptions options, int id,
std::unique_ptr<WebSocketListener> 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
@@ -0,0 +1,81 @@
#pragma once
#include "common/websockets/WebSocketPool.hpp"
#include "util/QByteArrayBuffer.hpp"
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/beast/core/flat_buffer.hpp>
#include <QDebug>
#include <deque>
#include <memory>
#include <utility>
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<WebSocketListener> 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<WebSocketListener> listener;
// nullable, used for signalling a disconnect
WebSocketPoolImpl *pool;
boost::asio::ip::tcp::resolver resolver;
std::deque<std::pair<bool, QByteArrayBuffer>> 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
@@ -0,0 +1,388 @@
#include "common/websockets/detail/WebSocketConnectionImpl.hpp"
#include "common/QLogging.hpp"
#include "common/Version.hpp"
#include <boost/asio/strand.hpp>
#include <boost/beast/core/bind_handler.hpp>
#include <boost/beast/websocket/ssl.hpp>
namespace chatterino::ws::detail {
namespace asio = boost::asio;
namespace beast = boost::beast;
// MARK: WebSocketConnectionHelper
template <typename Derived, typename Inner>
WebSocketConnectionHelper<Derived, Inner>::WebSocketConnectionHelper(
WebSocketOptions options, int id,
std::unique_ptr<WebSocketListener> listener, WebSocketPoolImpl *pool,
asio::io_context &ioc, Stream stream)
: WebSocketConnection(std::move(options), id, std::move(listener), pool,
ioc)
, stream(std::move(stream))
{
}
template <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::post(auto &&fn)
{
asio::post(this->stream.get_executor(), std::forward<decltype(fn)>(fn));
}
template <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::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 <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::close()
{
this->post([self{this->shared_from_this()}] {
self->closeImpl();
});
}
template <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::sendText(const QByteArray &data)
{
this->post([self{this->shared_from_this()}, data] {
self->queuedMessages.emplace_back(true, data);
self->trySend();
});
}
template <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::sendBinary(
const QByteArray &data)
{
this->post([self{this->shared_from_this()}, data] {
self->queuedMessages.emplace_back(false, data);
self->trySend();
});
}
template <typename Derived, typename Inner>
Derived *WebSocketConnectionHelper<Derived, Inner>::derived()
{
return static_cast<Derived *>(this);
}
template <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::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 <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::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 <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::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 <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::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 <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::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<const char *>(this->readBuffer.cdata().data()),
static_cast<QByteArray::size_type>(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 <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::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 <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::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 <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::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 <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::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<WebSocketListener> 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<int>(::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<WebSocketListener> 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
@@ -0,0 +1,115 @@
#pragma once
#include "common/websockets/detail/WebSocketConnection.hpp"
#include <boost/asio/ssl/context.hpp>
#include <boost/asio/ssl/stream.hpp>
#include <boost/beast/core/tcp_stream.hpp>
#include <boost/beast/websocket/stream.hpp>
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 <typename Derived, typename Inner>
class WebSocketConnectionHelper : public WebSocketConnection,
public std::enable_shared_from_this<
WebSocketConnectionHelper<Derived, Inner>>
{
public:
using Stream = boost::beast::websocket::stream<Inner>;
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<WebSocketListener> 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<boost::beast::tcp_stream>>
{
public:
static constexpr int DEFAULT_PORT = 443;
TlsWebSocketConnection(WebSocketOptions options, int id,
std::unique_ptr<WebSocketListener> 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<boost::beast::tcp_stream>>;
};
/// A WebSocket connection over TCP (ws://).
class TcpWebSocketConnection
: public WebSocketConnectionHelper<TcpWebSocketConnection,
boost::beast::tcp_stream>
{
public:
static constexpr int DEFAULT_PORT = 80;
TcpWebSocketConnection(WebSocketOptions options, int id,
std::unique_ptr<WebSocketListener> listener,
WebSocketPoolImpl *pool,
boost::asio::io_context &ioc);
protected:
void afterTcpHandshake();
friend WebSocketConnectionHelper<TcpWebSocketConnection,
boost::beast::tcp_stream>;
};
} // namespace chatterino::ws::detail
@@ -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 <boost/certify/https_verification.hpp>
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<std::thread>([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
@@ -0,0 +1,51 @@
#pragma once
#include "util/OnceFlag.hpp"
#include <boost/asio/io_context.hpp>
#include <boost/asio/ssl/context.hpp>
#include <chrono>
#include <memory>
#include <mutex>
#include <thread>
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<std::thread> ioThread;
boost::asio::io_context ioc;
boost::asio::ssl::context ssl;
boost::asio::executor_work_guard<boost::asio::io_context::executor_type>
work;
std::vector<std::shared_ptr<WebSocketConnection>> connections;
std::mutex connectionMutex;
bool closing = false;
int nextID = 1;
OnceFlag shutdownFlag;
};
} // namespace chatterino::ws::detail
+1
View File
@@ -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
*/
+7
View File
@@ -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
+1
View File
@@ -136,6 +136,7 @@ public:
bool hasFSPermissionFor(bool write, const QString &path);
bool hasHTTPPermissionFor(const QUrl &url);
bool hasNetworkPermission() const;
std::map<lua::api::EventType, sol::protected_function> callbacks;
@@ -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::api::EventType>(lua);
c2["LogLevel"] = lua::createEnumTable<lua::api::LogLevel>(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<bool, QStringList> PluginController::updateCustomCompletions(
return {false, results};
}
WebSocketPool &PluginController::webSocketPool()
{
return this->webSocketPool_;
}
} // namespace chatterino
#endif
@@ -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<QString, std::unique_ptr<Plugin>> plugins_;
WebSocketPool webSocketPool_;
// This is for tests, pay no attention
friend class PluginControllerAccess;
+10 -1
View File
@@ -1,10 +1,12 @@
#ifdef CHATTERINO_HAVE_PLUGINS
# include "controllers/plugins/SolTypes.hpp"
# include "common/QLogging.hpp"
# include "controllers/plugins/PluginController.hpp"
# include <QObject>
# include <sol/thread.hpp>
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<QByteArray>, lua_State *L, int index,
sol::stack::record &tracking)
{
auto str = sol::stack::get<std::string_view>(L, index, tracking);
return QByteArray::fromRawData(str.data(), str.length());
return {str.data(), static_cast<qsizetype>(str.length())};
}
int sol_lua_push(sol::types<QByteArray>, lua_State *L, const QByteArray &value)
+27 -2
View File
@@ -67,8 +67,12 @@ private:
/// `std::optional<T>` means nil|LuaEquiv<T> (or zero returns)
/// A return type that doesn't match returns an error
template <typename T, typename... Args>
inline nonstd::expected_lite::expected<T, QString> tryCall(
const sol::protected_function &function, Args &&...args)
inline nonstd::expected_lite::expected<T, QString> tryCall(const auto &function,
Args &&...args)
requires(std::same_as<std::remove_cvref_t<decltype(function)>,
sol::protected_function> ||
std::same_as<std::remove_cvref_t<decltype(function)>,
sol::main_protected_function>)
{
sol::protected_function_result result =
function(std::forward<Args>(args)...);
@@ -148,6 +152,27 @@ inline nonstd::expected_lite::expected<T, QString> tryCall(
}
}
void logError(Plugin *plugin, QStringView context, const QString &msg);
template <typename T>
bool hasValueOrLog(const nonstd::expected<T, QString> &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<void>(fn, std::forward<decltype(args)>(args)...);
hasValueOrLog(res, context, plugin);
}
// NOLINTNEXTLINE(cppcoreguidelines-macro-usage)
# define SOL_STACK_FUNCTIONS(TYPE) \
bool sol_lua_check(sol::types<TYPE>, lua_State *L, int index, \
+175
View File
@@ -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 <utility>
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<WebSocket> target);
void onClose(std::unique_ptr<WebSocketListener> self) override;
void onBinaryMessage(QByteArray data) override;
void onTextMessage(QByteArray data) override;
private:
std::weak_ptr<WebSocket> target;
};
WebSocket::WebSocket() = default;
void WebSocket::createUserType(sol::table &c2, Plugin *plugin)
{
c2.new_usertype<WebSocket>(
"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<WebSocket>();
self->plugin = plugin;
WebSocketOptions opts{.url = url, .headers = {}};
if (args.size() >= 1)
{
sol::table luaOpts = args[0];
sol::optional<sol::table> headers = luaOpts["headers"];
if (headers)
{
for (const auto &[k, v] : *headers)
{
opts.headers.emplace_back(k.as<std::string>(),
v.as<std::string>());
}
}
sol::optional<sol::main_function> onText = luaOpts["on_text"];
sol::optional<sol::main_function> onBinary =
luaOpts["on_binary"];
sol::optional<sol::main_function> 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<WebSocketListenerProxy>(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<WebSocket> target)
: target(std::move(target))
{
}
void WebSocketListenerProxy::onClose(std::unique_ptr<WebSocketListener> 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
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#ifdef CHATTERINO_HAVE_PLUGINS
# include "common/websockets/WebSocketPool.hpp"
# include <sol/protected_function.hpp>
# include <sol/types.hpp>
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<string, string>, 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
+15 -17
View File
@@ -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 <typename F>
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<F>::type;
using Fun = typename std::decay_t<decltype(f)>;
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<decltype(fun)>(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<F>(fun)));
QCoreApplication::postEvent(obj, new Event(std::forward<decltype(f)>(f)));
}
template <typename F>
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<decltype(fun)>(fun));
}
}
template <typename F>
inline void postToGuiThread(F &&fun)
inline void postToGuiThread(auto &&fun)
{
assert(!isGuiThread() &&
"postToGuiThread must be called from a non-GUI thread");
postToThread(std::forward<F>(fun));
postToThread(std::forward<decltype(fun)>(fun));
}
} // namespace chatterino
+120
View File
@@ -0,0 +1,120 @@
#pragma once
#include <boost/beast/core/buffer_traits.hpp>
#include <QByteArray>
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<size_t>(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);
+1
View File
@@ -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
+6
View File
@@ -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_);
+173
View File
@@ -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<std::pair<bool, QByteArray>> messages;
lua->set("done", [&] {
waiter.requestDone();
});
lua->set("add", [&](bool isText, QByteArray data) {
messages.emplace_back(isText, std::move(data));
});
std::shared_ptr<lua::api::WebSocket> 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<lua::api::WebSocket> 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<std::pair<bool, QByteArray>> messages;
lua->set("done", [&] {
waiter.requestDone();
});
lua->set("add", [&](bool isText, QByteArray data) {
messages.emplace_back(isText, std::move(data));
});
std::shared_ptr<lua::api::WebSocket> 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<lua::api::WebSocket> 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
+163
View File
@@ -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<std::pair<bool, QByteArray>> &messages,
OnceFlag &messageFlag, OnceFlag &closeFlag)
: messages(messages)
, messageFlag(messageFlag)
, closeFlag(closeFlag)
{
}
void onClose(std::unique_ptr<WebSocketListener> /*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<std::pair<bool, QByteArray>> &messages;
OnceFlag &messageFlag;
OnceFlag &closeFlag;
};
} // namespace
TEST(WebSocketPool, tcpEcho)
{
mock::BaseApplication app;
WebSocketPool pool;
std::vector<std::pair<bool, QByteArray>> 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<Listener>(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<std::pair<bool, QByteArray>> 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<Listener>(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"));
}