feat(plugins): add c2.WebSocket (#6076)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+23
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, \
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user