feat(plugins): add c2.WebSocket (#6076)
This commit is contained in:
@@ -11,7 +11,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TWITCH_PUBSUB_SERVER_TAG: v1.0.8
|
TWITCH_PUBSUB_SERVER_TAG: v1.0.11
|
||||||
HTTPBOX_TAG: v0.2.1
|
HTTPBOX_TAG: v0.2.1
|
||||||
QT_QPA_PLATFORM: minimal
|
QT_QPA_PLATFORM: minimal
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
httpbox --port 9051 &
|
httpbox --port 9051 &
|
||||||
cd ../pubsub-server-test
|
cd ../pubsub-server-test
|
||||||
./server 127.0.0.1:9050 &
|
./server 127.0.0.1:9050 127.0.0.1:9052 &
|
||||||
cd ../build-test
|
cd ../build-test
|
||||||
ctest --repeat until-pass:4 --output-on-failure
|
ctest --repeat until-pass:4 --output-on-failure
|
||||||
working-directory: build-test
|
working-directory: build-test
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
merge_group:
|
merge_group:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TWITCH_PUBSUB_SERVER_TAG: v1.0.8
|
TWITCH_PUBSUB_SERVER_TAG: v1.0.11
|
||||||
HTTPBOX_TAG: v0.2.1
|
HTTPBOX_TAG: v0.2.1
|
||||||
QT_QPA_PLATFORM: minimal
|
QT_QPA_PLATFORM: minimal
|
||||||
HOMEBREW_NO_AUTO_UPDATE: 1
|
HOMEBREW_NO_AUTO_UPDATE: 1
|
||||||
@@ -80,7 +80,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
httpbox --port 9051 &
|
httpbox --port 9051 &
|
||||||
cd ../pubsub-server-test
|
cd ../pubsub-server-test
|
||||||
./server 127.0.0.1:9050 &
|
./server 127.0.0.1:9050 127.0.0.1:9052 &
|
||||||
cd ../build-test
|
cd ../build-test
|
||||||
ctest --repeat until-pass:4 --output-on-failure
|
ctest --repeat until-pass:4 --output-on-failure
|
||||||
working-directory: build-test
|
working-directory: build-test
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
merge_group:
|
merge_group:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TWITCH_PUBSUB_SERVER_TAG: v1.0.8
|
TWITCH_PUBSUB_SERVER_TAG: v1.0.11
|
||||||
HTTPBOX_TAG: v0.2.1
|
HTTPBOX_TAG: v0.2.1
|
||||||
QT_QPA_PLATFORM: minimal
|
QT_QPA_PLATFORM: minimal
|
||||||
CONAN_VERSION: 2.11.0
|
CONAN_VERSION: 2.11.0
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
..\httpbox\httpbox.exe --port 9051 &
|
..\httpbox\httpbox.exe --port 9051 &
|
||||||
cd ..\pubsub-server-test
|
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
|
cd ..\build-test
|
||||||
ctest --repeat until-pass:4 --output-on-failure
|
ctest --repeat until-pass:4 --output-on-failure
|
||||||
working-directory: build-test
|
working-directory: build-test
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TWITCH_PUBSUB_SERVER_TAG: v1.0.8
|
TWITCH_PUBSUB_SERVER_TAG: v1.0.11
|
||||||
HTTPBOX_TAG: v0.2.1
|
HTTPBOX_TAG: v0.2.1
|
||||||
QT_QPA_PLATFORM: minimal
|
QT_QPA_PLATFORM: minimal
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
httpbox --port 9051 &
|
httpbox --port 9051 &
|
||||||
cd ../pubsub-server-test
|
cd ../pubsub-server-test
|
||||||
./server 127.0.0.1:9050 &
|
./server 127.0.0.1:9050 127.0.0.1:9052 &
|
||||||
cd ../build-test
|
cd ../build-test
|
||||||
ctest --repeat until-pass:4 --output-on-failure
|
ctest --repeat until-pass:4 --output-on-failure
|
||||||
working-directory: build-test
|
working-directory: build-test
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- Minor: Add an option for the reduced opacity of message history. (#6121)
|
- 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: 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: 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: Fixed scrolling now working on inputs in the settings. (#6128)
|
||||||
- Bugfix: Make reply-cancel button less coarse-grained. (#6106)
|
- Bugfix: Make reply-cancel button less coarse-grained. (#6106)
|
||||||
|
|||||||
Vendored
+23
-1
@@ -1,6 +1,6 @@
|
|||||||
/** @noSelfInFile */
|
/** @noSelfInFile */
|
||||||
|
|
||||||
declare module c2 {
|
declare namespace c2 {
|
||||||
enum LogLevel {
|
enum LogLevel {
|
||||||
Debug,
|
Debug,
|
||||||
Info,
|
Info,
|
||||||
@@ -129,4 +129,26 @@ declare module c2 {
|
|||||||
|
|
||||||
function register_callback<T>(type: T, func: CbFunc<T>): void;
|
function register_callback<T>(type: T, func: CbFunc<T>): void;
|
||||||
function later(callback: () => void, msec: number): 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
|
-- 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
|
-- Begin src/common/network/NetworkCommon.hpp
|
||||||
|
|
||||||
---@enum c2.HTTPMethod
|
---@enum c2.HTTPMethod
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ set(SOURCE_FILES
|
|||||||
common/network/NetworkTask.cpp
|
common/network/NetworkTask.cpp
|
||||||
common/network/NetworkTask.hpp
|
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.cpp
|
||||||
controllers/accounts/Account.hpp
|
controllers/accounts/Account.hpp
|
||||||
controllers/accounts/AccountController.cpp
|
controllers/accounts/AccountController.cpp
|
||||||
@@ -238,6 +247,8 @@ set(SOURCE_FILES
|
|||||||
controllers/plugins/api/HTTPResponse.hpp
|
controllers/plugins/api/HTTPResponse.hpp
|
||||||
controllers/plugins/api/IOWrapper.cpp
|
controllers/plugins/api/IOWrapper.cpp
|
||||||
controllers/plugins/api/IOWrapper.hpp
|
controllers/plugins/api/IOWrapper.hpp
|
||||||
|
controllers/plugins/api/WebSocket.cpp
|
||||||
|
controllers/plugins/api/WebSocket.hpp
|
||||||
controllers/plugins/LuaAPI.cpp
|
controllers/plugins/LuaAPI.cpp
|
||||||
controllers/plugins/LuaAPI.hpp
|
controllers/plugins/LuaAPI.hpp
|
||||||
controllers/plugins/LuaUtilities.cpp
|
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/ChannelRef.hpp
|
||||||
* @includefile controllers/plugins/api/HTTPResponse.hpp
|
* @includefile controllers/plugins/api/HTTPResponse.hpp
|
||||||
* @includefile controllers/plugins/api/HTTPRequest.hpp
|
* @includefile controllers/plugins/api/HTTPRequest.hpp
|
||||||
|
* @includefile controllers/plugins/api/WebSocket.hpp
|
||||||
* @includefile common/network/NetworkCommon.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
|
} // namespace chatterino
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ public:
|
|||||||
|
|
||||||
bool hasFSPermissionFor(bool write, const QString &path);
|
bool hasFSPermissionFor(bool write, const QString &path);
|
||||||
bool hasHTTPPermissionFor(const QUrl &url);
|
bool hasHTTPPermissionFor(const QUrl &url);
|
||||||
|
bool hasNetworkPermission() const;
|
||||||
|
|
||||||
std::map<lua::api::EventType, sol::protected_function> callbacks;
|
std::map<lua::api::EventType, sol::protected_function> callbacks;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
# include "controllers/plugins/api/HTTPRequest.hpp"
|
# include "controllers/plugins/api/HTTPRequest.hpp"
|
||||||
# include "controllers/plugins/api/HTTPResponse.hpp"
|
# include "controllers/plugins/api/HTTPResponse.hpp"
|
||||||
# include "controllers/plugins/api/IOWrapper.hpp"
|
# include "controllers/plugins/api/IOWrapper.hpp"
|
||||||
|
# include "controllers/plugins/api/WebSocket.hpp"
|
||||||
# include "controllers/plugins/LuaAPI.hpp"
|
# include "controllers/plugins/LuaAPI.hpp"
|
||||||
# include "controllers/plugins/LuaUtilities.hpp"
|
# include "controllers/plugins/LuaUtilities.hpp"
|
||||||
# include "controllers/plugins/SolTypes.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["EventType"] = lua::createEnumTable<lua::api::EventType>(lua);
|
||||||
c2["LogLevel"] = lua::createEnumTable<lua::api::LogLevel>(lua);
|
c2["LogLevel"] = lua::createEnumTable<lua::api::LogLevel>(lua);
|
||||||
|
|
||||||
|
if (plugin->hasNetworkPermission())
|
||||||
|
{
|
||||||
|
lua::api::WebSocket::createUserType(c2, plugin);
|
||||||
|
}
|
||||||
|
|
||||||
sol::table io = g["io"];
|
sol::table io = g["io"];
|
||||||
io.set_function(
|
io.set_function(
|
||||||
"open", sol::overload(&lua::api::io_open, &lua::api::io_open_modeless));
|
"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};
|
return {false, results};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WebSocketPool &PluginController::webSocketPool()
|
||||||
|
{
|
||||||
|
return this->webSocketPool_;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace chatterino
|
} // namespace chatterino
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||||
|
|
||||||
|
# include "common/websockets/WebSocketPool.hpp"
|
||||||
# include "controllers/commands/CommandContext.hpp"
|
# include "controllers/commands/CommandContext.hpp"
|
||||||
# include "controllers/plugins/Plugin.hpp"
|
# include "controllers/plugins/Plugin.hpp"
|
||||||
|
|
||||||
@@ -61,6 +62,8 @@ public:
|
|||||||
const QString &query, const QString &fullTextContent,
|
const QString &query, const QString &fullTextContent,
|
||||||
int cursorPosition, bool isFirstWord) const;
|
int cursorPosition, bool isFirstWord) const;
|
||||||
|
|
||||||
|
WebSocketPool &webSocketPool();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void loadPlugins();
|
void loadPlugins();
|
||||||
void load(const QFileInfo &index, const QDir &pluginDir,
|
void load(const QFileInfo &index, const QDir &pluginDir,
|
||||||
@@ -74,6 +77,7 @@ private:
|
|||||||
static void loadChatterinoLib(lua_State *l);
|
static void loadChatterinoLib(lua_State *l);
|
||||||
bool tryLoadFromDir(const QDir &pluginDir);
|
bool tryLoadFromDir(const QDir &pluginDir);
|
||||||
std::map<QString, std::unique_ptr<Plugin>> plugins_;
|
std::map<QString, std::unique_ptr<Plugin>> plugins_;
|
||||||
|
WebSocketPool webSocketPool_;
|
||||||
|
|
||||||
// This is for tests, pay no attention
|
// This is for tests, pay no attention
|
||||||
friend class PluginControllerAccess;
|
friend class PluginControllerAccess;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||||
# include "controllers/plugins/SolTypes.hpp"
|
# include "controllers/plugins/SolTypes.hpp"
|
||||||
|
|
||||||
|
# include "common/QLogging.hpp"
|
||||||
# include "controllers/plugins/PluginController.hpp"
|
# include "controllers/plugins/PluginController.hpp"
|
||||||
|
|
||||||
# include <QObject>
|
# include <QObject>
|
||||||
# include <sol/thread.hpp>
|
# include <sol/thread.hpp>
|
||||||
|
|
||||||
namespace chatterino::lua {
|
namespace chatterino::lua {
|
||||||
|
|
||||||
Plugin *ThisPluginState::plugin()
|
Plugin *ThisPluginState::plugin()
|
||||||
@@ -22,6 +24,13 @@ Plugin *ThisPluginState::plugin()
|
|||||||
return pl;
|
return pl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void logError(Plugin *plugin, QStringView context, const QString &msg)
|
||||||
|
{
|
||||||
|
qCWarning(chatterinoLua).noquote()
|
||||||
|
<< "[" + plugin->id + ":" + plugin->meta.name + "]" << context << "-"
|
||||||
|
<< msg;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace chatterino::lua
|
} // namespace chatterino::lua
|
||||||
|
|
||||||
// NOLINTBEGIN(readability-named-parameter)
|
// NOLINTBEGIN(readability-named-parameter)
|
||||||
@@ -89,7 +98,7 @@ QByteArray sol_lua_get(sol::types<QByteArray>, lua_State *L, int index,
|
|||||||
sol::stack::record &tracking)
|
sol::stack::record &tracking)
|
||||||
{
|
{
|
||||||
auto str = sol::stack::get<std::string_view>(L, index, 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)
|
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)
|
/// `std::optional<T>` means nil|LuaEquiv<T> (or zero returns)
|
||||||
/// A return type that doesn't match returns an error
|
/// A return type that doesn't match returns an error
|
||||||
template <typename T, typename... Args>
|
template <typename T, typename... Args>
|
||||||
inline nonstd::expected_lite::expected<T, QString> tryCall(
|
inline nonstd::expected_lite::expected<T, QString> tryCall(const auto &function,
|
||||||
const sol::protected_function &function, Args &&...args)
|
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 =
|
sol::protected_function_result result =
|
||||||
function(std::forward<Args>(args)...);
|
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)
|
// NOLINTNEXTLINE(cppcoreguidelines-macro-usage)
|
||||||
# define SOL_STACK_FUNCTIONS(TYPE) \
|
# define SOL_STACK_FUNCTIONS(TYPE) \
|
||||||
bool sol_lua_check(sol::types<TYPE>, lua_State *L, int index, \
|
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
|
// Taken from
|
||||||
// https://stackoverflow.com/questions/21646467/how-to-execute-a-functor-or-a-lambda-in-a-given-thread-in-qt-gcd-style
|
// 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
|
// Qt 5/4 - preferred, has least allocations
|
||||||
template <typename F>
|
static void postToThread(auto &&f, QObject *obj = QCoreApplication::instance())
|
||||||
static void postToThread(F &&fun, QObject *obj = QCoreApplication::instance())
|
|
||||||
{
|
{
|
||||||
struct Event : public QEvent {
|
struct Event : public QEvent {
|
||||||
using Fun = typename std::decay<F>::type;
|
using Fun = typename std::decay_t<decltype(f)>;
|
||||||
Fun fun;
|
Fun fun;
|
||||||
Event(Fun &&fun)
|
|
||||||
|
Event(decltype(fun) f)
|
||||||
: QEvent(QEvent::None)
|
: QEvent(QEvent::None)
|
||||||
, fun(std::move(fun))
|
, fun(std::forward<decltype(fun)>(f))
|
||||||
{
|
|
||||||
}
|
|
||||||
Event(const Fun &fun)
|
|
||||||
: QEvent(QEvent::None)
|
|
||||||
, fun(fun)
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
Event(const Event &) = delete;
|
||||||
|
Event(Event &&) = delete;
|
||||||
|
Event &operator=(const Event &) = delete;
|
||||||
|
Event &operator=(Event &&) = delete;
|
||||||
|
|
||||||
~Event() override
|
~Event() override
|
||||||
{
|
{
|
||||||
fun();
|
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(auto &&fun)
|
||||||
static void runInGuiThread(F &&fun)
|
|
||||||
{
|
{
|
||||||
if (isGuiThread())
|
if (isGuiThread())
|
||||||
{
|
{
|
||||||
@@ -42,17 +41,16 @@ static void runInGuiThread(F &&fun)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
postToThread(fun);
|
postToThread(std::forward<decltype(fun)>(fun));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
template <typename F>
|
inline void postToGuiThread(auto &&fun)
|
||||||
inline void postToGuiThread(F &&fun)
|
|
||||||
{
|
{
|
||||||
assert(!isGuiThread() &&
|
assert(!isGuiThread() &&
|
||||||
"postToGuiThread must be called from a non-GUI thread");
|
"postToGuiThread must be called from a non-GUI thread");
|
||||||
|
|
||||||
postToThread(std::forward<F>(fun));
|
postToThread(std::forward<decltype(fun)>(fun));
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace chatterino
|
} // 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/OnceFlag.cpp
|
||||||
${CMAKE_CURRENT_LIST_DIR}/src/IncognitoBrowser.cpp
|
${CMAKE_CURRENT_LIST_DIR}/src/IncognitoBrowser.cpp
|
||||||
${CMAKE_CURRENT_LIST_DIR}/src/EventSubMessages.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/NativeMessaging.cpp
|
||||||
|
|
||||||
${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.cpp
|
${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.cpp
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public:
|
|||||||
void waitForRequest()
|
void waitForRequest()
|
||||||
{
|
{
|
||||||
using namespace std::chrono_literals;
|
using namespace std::chrono_literals;
|
||||||
|
auto start = std::chrono::system_clock::now();
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
@@ -50,6 +51,11 @@ public:
|
|||||||
}
|
}
|
||||||
QCoreApplication::processEvents(QEventLoop::AllEvents);
|
QCoreApplication::processEvents(QEventLoop::AllEvents);
|
||||||
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
|
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
|
||||||
|
|
||||||
|
if (std::chrono::system_clock::now() - start > 2min)
|
||||||
|
{
|
||||||
|
throw std::runtime_error("Timeout");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ASSERT_TRUE(this->requestDone_);
|
ASSERT_TRUE(this->requestDone_);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
# include "controllers/commands/Command.hpp" // IWYU pragma: keep
|
# include "controllers/commands/Command.hpp" // IWYU pragma: keep
|
||||||
# include "controllers/commands/CommandController.hpp"
|
# include "controllers/commands/CommandController.hpp"
|
||||||
# include "controllers/plugins/api/ChannelRef.hpp"
|
# include "controllers/plugins/api/ChannelRef.hpp"
|
||||||
|
# include "controllers/plugins/api/WebSocket.hpp"
|
||||||
# include "controllers/plugins/Plugin.hpp"
|
# include "controllers/plugins/Plugin.hpp"
|
||||||
# include "controllers/plugins/PluginController.hpp"
|
# include "controllers/plugins/PluginController.hpp"
|
||||||
# include "controllers/plugins/PluginPermission.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
|
#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