From 451cbec0b90261c063a9ca359f5fd45e6b6ec401 Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:44:36 -0700 Subject: [PATCH] overlay: OBS control window via WebSocket API (#773) ## Link to GitHub Issue or related Pull Request, if one exists n/a ## Description of change Adds an overlay window widget for controlling OBS over WebSocket. Add new lines to the FPS widget that shows recording / streaming timers. Show notifications for major state changes (stream started/stopped, recording started/paused/resumed/stopped) ## Testing --- src/spice2x/CMakeLists.txt | 5 + .../external/easywsclient/easywsclient.cpp | 552 ++++++++++++++++++ .../external/easywsclient/easywsclient.hpp | 73 +++ src/spice2x/games/io.cpp | 2 + src/spice2x/games/io.h | 1 + src/spice2x/launcher/launcher.cpp | 21 + src/spice2x/launcher/options.cpp | 46 ++ src/spice2x/launcher/options.h | 7 +- src/spice2x/licenses.txt | 22 + src/spice2x/overlay/imgui/extensions.cpp | 14 + src/spice2x/overlay/imgui/extensions.h | 5 + src/spice2x/overlay/overlay.cpp | 7 + src/spice2x/overlay/overlay.h | 1 + src/spice2x/overlay/windows/exitprompt.cpp | 5 +- src/spice2x/overlay/windows/fps.cpp | 86 ++- src/spice2x/overlay/windows/fps.h | 3 + src/spice2x/overlay/windows/obs.cpp | 290 +++++++++ src/spice2x/overlay/windows/obs.h | 131 +++++ src/spice2x/overlay/windows/obs_websocket.cpp | 434 ++++++++++++++ 19 files changed, 1680 insertions(+), 25 deletions(-) create mode 100644 src/spice2x/external/easywsclient/easywsclient.cpp create mode 100644 src/spice2x/external/easywsclient/easywsclient.hpp create mode 100644 src/spice2x/overlay/windows/obs.cpp create mode 100644 src/spice2x/overlay/windows/obs.h create mode 100644 src/spice2x/overlay/windows/obs_websocket.cpp diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index cfcfa89..71228b4 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -605,10 +605,15 @@ set(SOURCE_FILES ${SOURCE_FILES} overlay/windows/keypad.cpp overlay/windows/log.cpp overlay/windows/midi.cpp + overlay/windows/obs.cpp + overlay/windows/obs_websocket.cpp overlay/windows/patch_manager.cpp overlay/windows/popn_sub.cpp overlay/windows/wnd_manager.cpp + # external + external/easywsclient/easywsclient.cpp + # rawinput rawinput/rawinput.cpp rawinput/sextet.cpp diff --git a/src/spice2x/external/easywsclient/easywsclient.cpp b/src/spice2x/external/easywsclient/easywsclient.cpp new file mode 100644 index 0000000..a183978 --- /dev/null +++ b/src/spice2x/external/easywsclient/easywsclient.cpp @@ -0,0 +1,552 @@ +// This code comes from: +// https://github.com/dhbaird/easywsclient +// +// To get the latest version: +// wget https://raw.github.com/dhbaird/easywsclient/master/easywsclient.hpp +// wget https://raw.github.com/dhbaird/easywsclient/master/easywsclient.cpp + +#include "easywsclient.hpp" + +#ifdef _WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #include + #include + #pragma comment( lib, "ws2_32" ) + #include + #include + #include + #include + #include + typedef SOCKET socket_t; + #ifndef _SSIZE_T_DEFINED + typedef int ssize_t; + #define _SSIZE_T_DEFINED + #endif + #ifndef _SOCKET_T_DEFINED + typedef SOCKET socket_t; + #define _SOCKET_T_DEFINED + #endif + #if defined(_MSC_VER) && !defined(snprintf) + #define snprintf _snprintf_s + #endif + #include + #define socketerrno WSAGetLastError() + #define SOCKET_EAGAIN_EINPROGRESS WSAEINPROGRESS + #define SOCKET_EWOULDBLOCK WSAEWOULDBLOCK +#else + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + typedef int socket_t; + #ifndef INVALID_SOCKET + #define INVALID_SOCKET (-1) + #endif + #ifndef SOCKET_ERROR + #define SOCKET_ERROR (-1) + #endif + #define closesocket(s) ::close(s) + #include + #define socketerrno errno + #define SOCKET_EAGAIN_EINPROGRESS EAGAIN + #define SOCKET_EWOULDBLOCK EWOULDBLOCK +#endif + +#include +#include + +#include + +#include "util/logging.h" + +// When false, easywsclient's diagnostics are suppressed. The host application +// opts in by setting this flag (see obs_websocket.cpp). +bool EASYWSCLIENT_LOGGING_ENABLED = false; + +// Route easywsclient's diagnostic output through the project logger instead of +// writing to stderr, without editing the upstream source lines below: these two +// macros redirect fprintf(stderr, ...) / fputs(..., stderr) to log_misc. +// Only emitted when EASYWSCLIENT_LOGGING_ENABLED is set. +static inline void easywsclient_logf(const char *fmt, ...) { + if (!EASYWSCLIENT_LOGGING_ENABLED) { + return; + } + char buf[1024]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + // trim trailing newline(s); the logger appends its own + size_t len = strlen(buf); + while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) { + buf[--len] = '\0'; + } + log_misc("easywsclient", "{}", buf); +} +#define fprintf(stream, ...) easywsclient_logf(__VA_ARGS__) +#define fputs(str, stream) easywsclient_logf("%s", str) + +using namespace easywsclient; + +namespace { // private module-only namespace + +socket_t hostname_connect(const std::string& hostname, int port) { + struct addrinfo hints; + struct addrinfo *result; + struct addrinfo *p; + int ret; + socket_t sockfd = INVALID_SOCKET; + char sport[16]; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + snprintf(sport, 16, "%d", port); + if ((ret = getaddrinfo(hostname.c_str(), sport, &hints, &result)) != 0) + { + fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(ret)); + return 1; + } + for(p = result; p != NULL; p = p->ai_next) + { + sockfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol); + if (sockfd == INVALID_SOCKET) { continue; } + if (connect(sockfd, p->ai_addr, p->ai_addrlen) != SOCKET_ERROR) { + break; + } + closesocket(sockfd); + sockfd = INVALID_SOCKET; + } + freeaddrinfo(result); + return sockfd; +} + + +class _DummyWebSocket : public easywsclient::WebSocket +{ + public: + void poll(int timeout) { } + void send(const std::string& message) { } + void sendBinary(const std::string& message) { } + void sendBinary(const std::vector& message) { } + void sendPing() { } + void close() { } + readyStateValues getReadyState() const { return CLOSED; } + void _dispatch(Callback_Imp & callable) { } + void _dispatchBinary(BytesCallback_Imp& callable) { } +}; + + +class _RealWebSocket : public easywsclient::WebSocket +{ + public: + // http://tools.ietf.org/html/rfc6455#section-5.2 Base Framing Protocol + // + // 0 1 2 3 + // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + // +-+-+-+-+-------+-+-------------+-------------------------------+ + // |F|R|R|R| opcode|M| Payload len | Extended payload length | + // |I|S|S|S| (4) |A| (7) | (16/64) | + // |N|V|V|V| |S| | (if payload len==126/127) | + // | |1|2|3| |K| | | + // +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + + // | Extended payload length continued, if payload len == 127 | + // + - - - - - - - - - - - - - - - +-------------------------------+ + // | |Masking-key, if MASK set to 1 | + // +-------------------------------+-------------------------------+ + // | Masking-key (continued) | Payload Data | + // +-------------------------------- - - - - - - - - - - - - - - - + + // : Payload Data continued ... : + // + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + // | Payload Data continued ... | + // +---------------------------------------------------------------+ + + struct wsheader_type { + unsigned header_size; + bool fin; + bool mask; + enum opcode_type { + CONTINUATION = 0x0, + TEXT_FRAME = 0x1, + BINARY_FRAME = 0x2, + CLOSE = 8, + PING = 9, + PONG = 0xa, + } opcode; + int N0; + uint64_t N; + uint8_t masking_key[4]; + }; + + std::vector rxbuf; + std::vector txbuf; + std::vector receivedData; + + socket_t sockfd; + readyStateValues readyState; + bool useMask; + bool isRxBad; + + _RealWebSocket(socket_t sockfd, bool useMask) : sockfd(sockfd), readyState(OPEN), useMask(useMask), isRxBad(false) { + } + + readyStateValues getReadyState() const { + return readyState; + } + + void poll(int timeout) { // timeout in milliseconds + if (readyState == CLOSED) { + if (timeout > 0) { + timeval tv = { timeout/1000, (timeout%1000) * 1000 }; + select(0, NULL, NULL, NULL, &tv); + } + return; + } + if (timeout != 0) { + fd_set rfds; + fd_set wfds; + timeval tv = { timeout/1000, (timeout%1000) * 1000 }; + FD_ZERO(&rfds); + FD_ZERO(&wfds); + FD_SET(sockfd, &rfds); + if (txbuf.size()) { FD_SET(sockfd, &wfds); } + select(sockfd + 1, &rfds, &wfds, NULL, timeout > 0 ? &tv : NULL); + } + while (true) { + // FD_ISSET(0, &rfds) will be true + int N = rxbuf.size(); + ssize_t ret; + rxbuf.resize(N + 1500); + ret = recv(sockfd, (char*)&rxbuf[0] + N, 1500, 0); + if (false) { } + else if (ret < 0 && (socketerrno == SOCKET_EWOULDBLOCK || socketerrno == SOCKET_EAGAIN_EINPROGRESS)) { + rxbuf.resize(N); + break; + } + else if (ret <= 0) { + rxbuf.resize(N); + closesocket(sockfd); + readyState = CLOSED; + fputs(ret < 0 ? "Connection error!\n" : "Connection closed!\n", stderr); + break; + } + else { + rxbuf.resize(N + ret); + } + } + while (txbuf.size()) { + int ret = ::send(sockfd, (char*)&txbuf[0], txbuf.size(), 0); + if (false) { } // ?? + else if (ret < 0 && (socketerrno == SOCKET_EWOULDBLOCK || socketerrno == SOCKET_EAGAIN_EINPROGRESS)) { + break; + } + else if (ret <= 0) { + closesocket(sockfd); + readyState = CLOSED; + fputs(ret < 0 ? "Connection error!\n" : "Connection closed!\n", stderr); + break; + } + else { + txbuf.erase(txbuf.begin(), txbuf.begin() + ret); + } + } + if (!txbuf.size() && readyState == CLOSING) { + closesocket(sockfd); + readyState = CLOSED; + } + } + + virtual void _dispatch(Callback_Imp & callable) { + struct CallbackAdapter : public BytesCallback_Imp + // Adapt void(const std::string&) to void(const std::string&) + { + Callback_Imp& callable; + CallbackAdapter(Callback_Imp& callable) : callable(callable) { } + void operator()(const std::vector& message) { + std::string stringMessage(message.begin(), message.end()); + callable(stringMessage); + } + }; + CallbackAdapter bytesCallback(callable); + _dispatchBinary(bytesCallback); + } + + virtual void _dispatchBinary(BytesCallback_Imp& callable) { + // TODO: consider acquiring a lock on rxbuf... + while (true) { + wsheader_type ws; + if (rxbuf.size() < 2) { return; /* Need at least 2 */ } + const uint8_t * data = (uint8_t *) &rxbuf[0]; // peek, but don't consume + ws.fin = (data[0] & 0x80) == 0x80; + ws.opcode = (wsheader_type::opcode_type) (data[0] & 0x0f); + ws.mask = (data[1] & 0x80) == 0x80; + ws.N0 = (data[1] & 0x7f); + ws.header_size = 2 + (ws.N0 == 126? 2 : 0) + (ws.N0 == 127? 8 : 0) + (ws.mask? 4 : 0); + if (rxbuf.size() < ws.header_size) { return; /* Need: ws.header_size - rxbuf.size() */ } + int i = 0; + if (ws.N0 < 126) { + ws.N = ws.N0; + i = 2; + } + else if (ws.N0 == 126) { + ws.N = 0; + ws.N |= ((uint64_t) data[2]) << 8; + ws.N |= ((uint64_t) data[3]) << 0; + i = 4; + } + else if (ws.N0 == 127) { + ws.N = 0; + ws.N |= ((uint64_t) data[2]) << 56; + ws.N |= ((uint64_t) data[3]) << 48; + ws.N |= ((uint64_t) data[4]) << 40; + ws.N |= ((uint64_t) data[5]) << 32; + ws.N |= ((uint64_t) data[6]) << 24; + ws.N |= ((uint64_t) data[7]) << 16; + ws.N |= ((uint64_t) data[8]) << 8; + ws.N |= ((uint64_t) data[9]) << 0; + i = 10; + } + if (ws.mask) { + ws.masking_key[0] = ((uint8_t) data[i+0]) << 0; + ws.masking_key[1] = ((uint8_t) data[i+1]) << 0; + ws.masking_key[2] = ((uint8_t) data[i+2]) << 0; + ws.masking_key[3] = ((uint8_t) data[i+3]) << 0; + } + else { + ws.masking_key[0] = 0; + ws.masking_key[1] = 0; + ws.masking_key[2] = 0; + ws.masking_key[3] = 0; + } + + // Note: The checks above should hopefully ensure this addition + // cannot overflow: + if (rxbuf.size() < ws.header_size+ws.N) { return; /* Need: ws.header_size+ws.N - rxbuf.size() */ } + + // We got a whole message, now do something with it: + if (false) { } + else if ( + ws.opcode == wsheader_type::TEXT_FRAME + || ws.opcode == wsheader_type::BINARY_FRAME + || ws.opcode == wsheader_type::CONTINUATION + ) { + if (ws.mask) { for (size_t i = 0; i != ws.N; ++i) { rxbuf[i+ws.header_size] ^= ws.masking_key[i&0x3]; } } + receivedData.insert(receivedData.end(), rxbuf.begin()+ws.header_size, rxbuf.begin()+ws.header_size+(size_t)ws.N);// just feed + if (ws.fin) { + callable((const std::vector) receivedData); + receivedData.erase(receivedData.begin(), receivedData.end()); + std::vector ().swap(receivedData);// free memory + } + } + else if (ws.opcode == wsheader_type::PING) { + if (ws.mask) { for (size_t i = 0; i != ws.N; ++i) { rxbuf[i+ws.header_size] ^= ws.masking_key[i&0x3]; } } + std::string data(rxbuf.begin()+ws.header_size, rxbuf.begin()+ws.header_size+(size_t)ws.N); + sendData(wsheader_type::PONG, data.size(), data.begin(), data.end()); + } + else if (ws.opcode == wsheader_type::PONG) { } + else if (ws.opcode == wsheader_type::CLOSE) { close(); } + else { fprintf(stderr, "ERROR: Got unexpected WebSocket message.\n"); close(); } + + rxbuf.erase(rxbuf.begin(), rxbuf.begin() + ws.header_size+(size_t)ws.N); + } + } + + void sendPing() { + std::string empty; + sendData(wsheader_type::PING, empty.size(), empty.begin(), empty.end()); + } + + void send(const std::string& message) { + sendData(wsheader_type::TEXT_FRAME, message.size(), message.begin(), message.end()); + } + + void sendBinary(const std::string& message) { + sendData(wsheader_type::BINARY_FRAME, message.size(), message.begin(), message.end()); + } + + void sendBinary(const std::vector& message) { + sendData(wsheader_type::BINARY_FRAME, message.size(), message.begin(), message.end()); + } + + template + void sendData(wsheader_type::opcode_type type, uint64_t message_size, Iterator message_begin, Iterator message_end) { + // TODO: + // Masking key should (must) be derived from a high quality random + // number generator, to mitigate attacks on non-WebSocket friendly + // middleware: + const uint8_t masking_key[4] = { 0x12, 0x34, 0x56, 0x78 }; + // TODO: consider acquiring a lock on txbuf... + if (readyState == CLOSING || readyState == CLOSED) { return; } + std::vector header; + header.assign(2 + (message_size >= 126 ? 2 : 0) + (message_size >= 65536 ? 6 : 0) + (useMask ? 4 : 0), 0); + header[0] = 0x80 | type; + if (false) { } + else if (message_size < 126) { + header[1] = (message_size & 0xff) | (useMask ? 0x80 : 0); + if (useMask) { + header[2] = masking_key[0]; + header[3] = masking_key[1]; + header[4] = masking_key[2]; + header[5] = masking_key[3]; + } + } + else if (message_size < 65536) { + header[1] = 126 | (useMask ? 0x80 : 0); + header[2] = (message_size >> 8) & 0xff; + header[3] = (message_size >> 0) & 0xff; + if (useMask) { + header[4] = masking_key[0]; + header[5] = masking_key[1]; + header[6] = masking_key[2]; + header[7] = masking_key[3]; + } + } + else { // TODO: run coverage testing here + header[1] = 127 | (useMask ? 0x80 : 0); + header[2] = (message_size >> 56) & 0xff; + header[3] = (message_size >> 48) & 0xff; + header[4] = (message_size >> 40) & 0xff; + header[5] = (message_size >> 32) & 0xff; + header[6] = (message_size >> 24) & 0xff; + header[7] = (message_size >> 16) & 0xff; + header[8] = (message_size >> 8) & 0xff; + header[9] = (message_size >> 0) & 0xff; + if (useMask) { + header[10] = masking_key[0]; + header[11] = masking_key[1]; + header[12] = masking_key[2]; + header[13] = masking_key[3]; + } + } + // N.B. - txbuf will keep growing until it can be transmitted over the socket: + txbuf.insert(txbuf.end(), header.begin(), header.end()); + txbuf.insert(txbuf.end(), message_begin, message_end); + if (useMask) { + size_t message_offset = txbuf.size() - message_size; + for (size_t i = 0; i != message_size; ++i) { + txbuf[message_offset + i] ^= masking_key[i&0x3]; + } + } + } + + void close() { + if(readyState == CLOSING || readyState == CLOSED) { return; } + readyState = CLOSING; + uint8_t closeFrame[6] = {0x88, 0x80, 0x00, 0x00, 0x00, 0x00}; // last 4 bytes are a masking key + std::vector header(closeFrame, closeFrame+6); + txbuf.insert(txbuf.end(), header.begin(), header.end()); + } + +}; + + +easywsclient::WebSocket::pointer from_url(const std::string& url, bool useMask, const std::string& origin) { + char host[512]; + int port; + char path[512]; + if (url.size() >= 512) { + fprintf(stderr, "ERROR: url size limit exceeded: %s\n", url.c_str()); + return NULL; + } + if (origin.size() >= 200) { + fprintf(stderr, "ERROR: origin size limit exceeded: %s\n", origin.c_str()); + return NULL; + } + if (false) { } + else if (sscanf(url.c_str(), "ws://%[^:/]:%d/%s", host, &port, path) == 3) { + } + else if (sscanf(url.c_str(), "ws://%[^:/]/%s", host, path) == 2) { + port = 80; + } + else if (sscanf(url.c_str(), "ws://%[^:/]:%d", host, &port) == 2) { + path[0] = '\0'; + } + else if (sscanf(url.c_str(), "ws://%[^:/]", host) == 1) { + port = 80; + path[0] = '\0'; + } + else { + fprintf(stderr, "ERROR: Could not parse WebSocket url: %s\n", url.c_str()); + return NULL; + } + //fprintf(stderr, "easywsclient: connecting: host=%s port=%d path=/%s\n", host, port, path); + socket_t sockfd = hostname_connect(host, port); + if (sockfd == INVALID_SOCKET) { + fprintf(stderr, "Unable to connect to %s:%d\n", host, port); + return NULL; + } + { + // XXX: this should be done non-blocking, + char line[1024]; + int status; + int i; + snprintf(line, 1024, "GET /%s HTTP/1.1\r\n", path); ::send(sockfd, line, strlen(line), 0); + if (port == 80) { + snprintf(line, 1024, "Host: %s\r\n", host); ::send(sockfd, line, strlen(line), 0); + } + else { + snprintf(line, 1024, "Host: %s:%d\r\n", host, port); ::send(sockfd, line, strlen(line), 0); + } + snprintf(line, 1024, "Upgrade: websocket\r\n"); ::send(sockfd, line, strlen(line), 0); + snprintf(line, 1024, "Connection: Upgrade\r\n"); ::send(sockfd, line, strlen(line), 0); + if (!origin.empty()) { + snprintf(line, 1024, "Origin: %s\r\n", origin.c_str()); ::send(sockfd, line, strlen(line), 0); + } + snprintf(line, 1024, "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==\r\n"); ::send(sockfd, line, strlen(line), 0); + snprintf(line, 1024, "Sec-WebSocket-Version: 13\r\n"); ::send(sockfd, line, strlen(line), 0); + snprintf(line, 1024, "\r\n"); ::send(sockfd, line, strlen(line), 0); + for (i = 0; i < 2 || (i < 1023 && line[i-2] != '\r' && line[i-1] != '\n'); ++i) { if (recv(sockfd, line+i, 1, 0) == 0) { return NULL; } } + line[i] = 0; + if (i == 1023) { fprintf(stderr, "ERROR: Got invalid status line connecting to: %s\n", url.c_str()); return NULL; } + if (sscanf(line, "HTTP/1.1 %d", &status) != 1 || status != 101) { fprintf(stderr, "ERROR: Got bad status connecting to %s: %s", url.c_str(), line); return NULL; } + // TODO: verify response headers, + while (true) { + for (i = 0; i < 2 || (i < 1023 && line[i-2] != '\r' && line[i-1] != '\n'); ++i) { if (recv(sockfd, line+i, 1, 0) == 0) { return NULL; } } + if (line[0] == '\r' && line[1] == '\n') { break; } + } + } + int flag = 1; + setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, (char*) &flag, sizeof(flag)); // Disable Nagle's algorithm +#ifdef _WIN32 + u_long on = 1; + ioctlsocket(sockfd, FIONBIO, &on); +#else + fcntl(sockfd, F_SETFL, O_NONBLOCK); +#endif + //fprintf(stderr, "Connected to: %s\n", url.c_str()); + return easywsclient::WebSocket::pointer(new _RealWebSocket(sockfd, useMask)); +} + +} // end of module-only namespace + + + +namespace easywsclient { + +WebSocket::pointer WebSocket::create_dummy() { + static pointer dummy = pointer(new _DummyWebSocket); + return dummy; +} + + +WebSocket::pointer WebSocket::from_url(const std::string& url, const std::string& origin) { + return ::from_url(url, true, origin); +} + +WebSocket::pointer WebSocket::from_url_no_mask(const std::string& url, const std::string& origin) { + return ::from_url(url, false, origin); +} + + +} // namespace easywsclient diff --git a/src/spice2x/external/easywsclient/easywsclient.hpp b/src/spice2x/external/easywsclient/easywsclient.hpp new file mode 100644 index 0000000..0cad04b --- /dev/null +++ b/src/spice2x/external/easywsclient/easywsclient.hpp @@ -0,0 +1,73 @@ +#ifndef EASYWSCLIENT_HPP_20120819_MIOFVASDTNUASZDQPLFD +#define EASYWSCLIENT_HPP_20120819_MIOFVASDTNUASZDQPLFD + +// This code comes from: +// https://github.com/dhbaird/easywsclient +// +// To get the latest version: +// wget https://raw.github.com/dhbaird/easywsclient/master/easywsclient.hpp +// wget https://raw.github.com/dhbaird/easywsclient/master/easywsclient.cpp + +#include +#include +#include + +namespace easywsclient { + +struct Callback_Imp { virtual void operator()(const std::string& message) = 0; }; +struct BytesCallback_Imp { virtual void operator()(const std::vector& message) = 0; }; + +class WebSocket { + public: + typedef WebSocket * pointer; + typedef enum readyStateValues { CLOSING, CLOSED, CONNECTING, OPEN } readyStateValues; + + // Factories: + static pointer create_dummy(); + static pointer from_url(const std::string& url, const std::string& origin = std::string()); + static pointer from_url_no_mask(const std::string& url, const std::string& origin = std::string()); + + // Interfaces: + virtual ~WebSocket() { } + virtual void poll(int timeout = 0) = 0; // timeout in milliseconds + virtual void send(const std::string& message) = 0; + virtual void sendBinary(const std::string& message) = 0; + virtual void sendBinary(const std::vector& message) = 0; + virtual void sendPing() = 0; + virtual void close() = 0; + virtual readyStateValues getReadyState() const = 0; + + template + void dispatch(Callable callable) + // For callbacks that accept a string argument. + { // N.B. this is compatible with both C++11 lambdas, functors and C function pointers + struct _Callback : public Callback_Imp { + Callable& callable; + _Callback(Callable& callable) : callable(callable) { } + void operator()(const std::string& message) { callable(message); } + }; + _Callback callback(callable); + _dispatch(callback); + } + + template + void dispatchBinary(Callable callable) + // For callbacks that accept a std::vector argument. + { // N.B. this is compatible with both C++11 lambdas, functors and C function pointers + struct _Callback : public BytesCallback_Imp { + Callable& callable; + _Callback(Callable& callable) : callable(callable) { } + void operator()(const std::vector& message) { callable(message); } + }; + _Callback callback(callable); + _dispatchBinary(callback); + } + + protected: + virtual void _dispatch(Callback_Imp& callable) = 0; + virtual void _dispatchBinary(BytesCallback_Imp& callable) = 0; +}; + +} // namespace easywsclient + +#endif /* EASYWSCLIENT_HPP_20120819_MIOFVASDTNUASZDQPLFD */ diff --git a/src/spice2x/games/io.cpp b/src/spice2x/games/io.cpp index f9a6eac..bd71ef9 100644 --- a/src/spice2x/games/io.cpp +++ b/src/spice2x/games/io.cpp @@ -474,6 +474,8 @@ namespace games { vkey_defaults.push_back(VK_F12); names.emplace_back("Toggle Camera Control"); vkey_defaults.push_back(0xFF); + names.emplace_back("Toggle OBS Control"); + vkey_defaults.push_back(0xFF); names.emplace_back("Player 1 PIN Macro"); vkey_defaults.push_back(0xFF); names.emplace_back("Player 2 PIN Macro"); diff --git a/src/spice2x/games/io.h b/src/spice2x/games/io.h index 544316a..19616b9 100644 --- a/src/spice2x/games/io.h +++ b/src/spice2x/games/io.h @@ -23,6 +23,7 @@ namespace games { ToggleScreenResize, ToggleFps, ToggleCameraControl, + ToggleOBSControl, TriggerPinMacroP1, TriggerPinMacroP2, ScreenResize, diff --git a/src/spice2x/launcher/launcher.cpp b/src/spice2x/launcher/launcher.cpp index 69ce4a5..191600f 100644 --- a/src/spice2x/launcher/launcher.cpp +++ b/src/spice2x/launcher/launcher.cpp @@ -102,6 +102,7 @@ #include "overlay/notifications.h" #include "overlay/windows/patch_manager.h" #include "overlay/windows/iidx_seg.h" +#include "overlay/windows/obs.h" #include "rawinput/rawinput.h" #include "rawinput/touch.h" #include "reader/reader.h" @@ -1437,6 +1438,26 @@ int main_implementation(int argc, char *argv[]) { cfg::CONFIGURATOR_FORCE_SOFTWARE_RENDER = true; } + // OBS WebSocket overlay settings + if (options[launcher::Options::OBSWebSocketEnabled].value_bool()) { + overlay::windows::OBS_CONTROL_ENABLED = true; + } + if (options[launcher::Options::OBSWebSocketHost].is_active()) { + overlay::windows::OBS_CONTROL_HOST = options[launcher::Options::OBSWebSocketHost].value_text(); + } + if (options[launcher::Options::OBSWebSocketPort].is_active()) { + const auto obs_port = options[launcher::Options::OBSWebSocketPort].value_uint32(); + if (obs_port > 0 && obs_port <= 65535) { + overlay::windows::OBS_CONTROL_PORT = static_cast(obs_port); + } + } + if (options[launcher::Options::OBSWebSocketPassword].is_active()) { + overlay::windows::OBS_CONTROL_PASSWORD = options[launcher::Options::OBSWebSocketPassword].value_text(); + } + if (options[launcher::Options::OBSWebSocketDebug].value_bool()) { + overlay::windows::OBS_CONTROL_DEBUG = true; + } + // API debugging if (api_debug && !cfg::CONFIGURATOR_STANDALONE) { API_CONTROLLER = std::make_unique(api_port, api_pass, api_pretty); diff --git a/src/spice2x/launcher/options.cpp b/src/spice2x/launcher/options.cpp index 0d57e7d..45196da 100644 --- a/src/spice2x/launcher/options.cpp +++ b/src/spice2x/launcher/options.cpp @@ -52,6 +52,7 @@ static const std::vector CATEGORY_ORDER_NETWORK = { static const std::vector CATEGORY_ORDER_OVERLAY = { "General Overlay", "Game Overlay", + "OBS Control", }; static const std::vector CATEGORY_ORDER_ADVANCED = { @@ -3201,6 +3202,51 @@ static const std::vector OPTION_DEFINITIONS = { .type = OptionType::Bool, .category = "Development", }, + { + // OBSWebSocketEnabled + .title = "OBS WebSocket Enable", + .name = "obsenable", + .desc = "Enables the in-game OBS Control overlay and its connection to the OBS Studio " + "obs-websocket (v5) server.", + .type = OptionType::Bool, + .category = "OBS Control", + }, + { + // OBSWebSocketHost + .title = "OBS WebSocket Host", + .name = "obshost", + .desc = "Host name or IP address of the OBS Studio obs-websocket (v5) server used by " + "the in-game OBS Control overlay. Defaults to 127.0.0.1 when left empty.", + .type = OptionType::Text, + .category = "OBS Control", + }, + { + // OBSWebSocketPort + .title = "OBS WebSocket Port", + .name = "obsport", + .desc = "Port of the OBS Studio obs-websocket (v5) server. Defaults to 4455 when left empty.", + .type = OptionType::Integer, + .category = "OBS Control", + }, + { + // OBSWebSocketPassword + .title = "OBS WebSocket Password", + .name = "obspass", + .desc = "Password for the OBS Studio obs-websocket (v5) server. Leave empty if " + "authentication is disabled in OBS.", + .type = OptionType::Text, + .category = "OBS Control", + .sensitive = true, + }, + { + // OBSWebSocketDebug + .title = "OBS WebSocket Debug", + .name = "obsdebug", + .desc = "Writes the OBS WebSocket client's internal connection diagnostics to the log. " + "Only enable this when troubleshooting connection problems.", + .type = OptionType::Bool, + .category = "OBS Control", + }, }; const std::vector &launcher::get_categories(Options::OptionsCategory category) { diff --git a/src/spice2x/launcher/options.h b/src/spice2x/launcher/options.h index cdb5d1e..8886714 100644 --- a/src/spice2x/launcher/options.h +++ b/src/spice2x/launcher/options.h @@ -305,7 +305,12 @@ namespace launcher { DisableHighResTimer, EnableICMPHook, AutoElevate, - CfgForceSoftwareRender + CfgForceSoftwareRender, + OBSWebSocketEnabled, + OBSWebSocketHost, + OBSWebSocketPort, + OBSWebSocketPassword, + OBSWebSocketDebug }; enum class OptionsCategory { diff --git a/src/spice2x/licenses.txt b/src/spice2x/licenses.txt index d739e29..d7a9b3a 100644 --- a/src/spice2x/licenses.txt +++ b/src/spice2x/licenses.txt @@ -1327,6 +1327,28 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +easywsclient (MIT) +------------------------------------------- +Copyright (c) 2012 Dhruv Matani, Daniel Baird + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + Contributions ------------------------------------------- cardio - Felix - MIT License diff --git a/src/spice2x/overlay/imgui/extensions.cpp b/src/spice2x/overlay/imgui/extensions.cpp index 2c95660..1dc60de 100644 --- a/src/spice2x/overlay/imgui/extensions.cpp +++ b/src/spice2x/overlay/imgui/extensions.cpp @@ -1,4 +1,5 @@ #include "extensions.h" +#include #include #include "external/imgui/imgui.h" @@ -161,6 +162,19 @@ namespace ImGui { return clicked; } + bool ColoredButton(const char* label, const ImVec4& base, const ImVec2& size) { + const auto brighten = [](const ImVec4& c, float d) { + return ImVec4((std::min)(c.x + d, 1.0f), (std::min)(c.y + d, 1.0f), + (std::min)(c.z + d, 1.0f), c.w); + }; + ImGui::PushStyleColor(ImGuiCol_Button, base); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, brighten(base, 0.12f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, brighten(base, 0.22f)); + const bool clicked = ImGui::Button(label, size); + ImGui::PopStyleColor(3); + return clicked; + } + bool ClearButton(const std::string& tooltip) { ImGui::PushID(tooltip.c_str()); // same colors as a checkbox diff --git a/src/spice2x/overlay/imgui/extensions.h b/src/spice2x/overlay/imgui/extensions.h index 151ef62..37ad082 100644 --- a/src/spice2x/overlay/imgui/extensions.h +++ b/src/spice2x/overlay/imgui/extensions.h @@ -22,6 +22,11 @@ namespace ImGui { bool ClearButton(const std::string& tooltip); void HighlightTableRowOnHover(); + // a Button with the given base fill color; the hovered/active shades are + // derived by brightening the base. size defaults to auto (fit the label). + bool ColoredButton(const char* label, const ImVec4& base, + const ImVec2& size = ImVec2(0, 0)); + // Config tab bar with extra label padding and uniform, centered tab widths. // Wrap items in BeginPaddedTabItem between BeginPaddedTabBar/EndTabBar. bool BeginPaddedTabBar(const char* str_id, ImGuiTabBarFlags flags = 0); diff --git a/src/spice2x/overlay/overlay.cpp b/src/spice2x/overlay/overlay.cpp index fd841d6..6ffd097 100644 --- a/src/spice2x/overlay/overlay.cpp +++ b/src/spice2x/overlay/overlay.cpp @@ -50,6 +50,7 @@ #include "windows/sdvx_sub.h" #include "windows/keypad.h" #include "windows/log.h" +#include "windows/obs.h" #include "windows/patch_manager.h" #include "windows/exitprompt.cpp" @@ -416,6 +417,12 @@ void overlay::SpiceOverlay::init() { } this->window_add(new overlay::windows::PatchManager(this)); + // OBS control spawns a background WebSocket worker; skip it in the standalone + // configurator, where there is no running game to stream/record + if (!cfg::CONFIGURATOR_STANDALONE) { + this->window_add(window_obs = new overlay::windows::OBSControl(this)); + } + { window_keypad1 = new overlay::windows::Keypad(this, 0); this->window_add(window_keypad1); diff --git a/src/spice2x/overlay/overlay.h b/src/spice2x/overlay/overlay.h index add1772..22d1021 100644 --- a/src/spice2x/overlay/overlay.h +++ b/src/spice2x/overlay/overlay.h @@ -77,6 +77,7 @@ namespace overlay { Window *window_camera = nullptr; Window *window_sub = nullptr; Window *window_log = nullptr; + Window *window_obs = nullptr; // not part of `windows`: drawn/updated on the persistent layer (like // notifications), independent of the overlay's active state and input gates. diff --git a/src/spice2x/overlay/windows/exitprompt.cpp b/src/spice2x/overlay/windows/exitprompt.cpp index 56515c5..48956ea 100644 --- a/src/spice2x/overlay/windows/exitprompt.cpp +++ b/src/spice2x/overlay/windows/exitprompt.cpp @@ -119,8 +119,9 @@ namespace overlay::windows { ImGui::TextDisabled("Graphics"); build_button(this->overlay->window_camera, "Camera control", size, NextItem::NEW_LINE); - build_button(this->overlay->window_fps.get(), "FPS", size_half, NextItem::SAME_LINE); - build_button(this->overlay->window_resize, "Resize", size_half, NextItem::NEW_LINE); + build_button(this->overlay->window_fps.get(), "FPS", size_third, NextItem::SAME_LINE); + build_button(this->overlay->window_obs, "OBS", size_third, NextItem::SAME_LINE); + build_button(this->overlay->window_resize, "Resize", size_third, NextItem::NEW_LINE); ImGui::TextDisabled("I/O"); build_button(this->overlay->window_cards, "Card Manager", size, NextItem::NEW_LINE); diff --git a/src/spice2x/overlay/windows/fps.cpp b/src/spice2x/overlay/windows/fps.cpp index 36e6ad2..176eec1 100644 --- a/src/spice2x/overlay/windows/fps.cpp +++ b/src/spice2x/overlay/windows/fps.cpp @@ -1,6 +1,7 @@ #include #include "external/fmt/include/fmt/chrono.h" #include "fps.h" +#include "obs.h" namespace overlay::windows { @@ -14,6 +15,7 @@ namespace overlay::windows { this->title = "Stats"; this->flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize + | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBringToFrontOnFocus @@ -29,25 +31,7 @@ namespace overlay::windows { std::chrono::floor(std::chrono::system_clock::now()); } - void FPS::calculate_initial_window() { - // size the window explicitly (no AlwaysAutoResize) so the corner anchoring - // below is exact; the footprint mirrors the fixed-fit table in build_content() - const float line_h = ImGui::GetTextLineHeight(); - const int rows = 3; - - // widest label and widest value drive the two fixed-fit columns - const float label_w = (std::max)( - ImGui::CalcTextSize("Time").x, - ImGui::CalcTextSize("Game").x); - const float value_w = ImGui::CalcTextSize("00:00:00").x; - - const float win_w = label_w + value_w - + FPS_CELL_PADDING.x * 2 - + FPS_WINDOW_PADDING.x * 2; - const float win_h = (line_h + FPS_CELL_PADDING.y * 2) * rows - + FPS_WINDOW_PADDING.y * 2; - this->init_size = ImVec2(win_w, win_h); - + ImVec2 FPS::anchored_pos(const ImVec2 &size) const { // bottom-anchored windows use a larger edge margin (matching notification // toasts) since they overlap the same on-screen UI; other edges hug closer const float edge_margin = overlay::apply_scaling(4); @@ -61,9 +45,27 @@ namespace overlay::windows { overlay::FPS_LOCATION == overlay::FpsLocation::BottomLeft || overlay::FPS_LOCATION == overlay::FpsLocation::BottomRight; - const float pos_x = right ? display.x - win_w - edge_margin : edge_margin; - const float pos_y = bottom ? display.y - win_h - bottom_margin : edge_margin; - this->init_pos = ImVec2(pos_x, pos_y); + const float pos_x = right ? display.x - size.x - edge_margin : edge_margin; + const float pos_y = bottom ? display.y - size.y - bottom_margin : edge_margin; + return ImVec2(pos_x, pos_y); + } + + void FPS::calculate_initial_window() { + // first-frame size estimate for the base 3 rows; AlwaysAutoResize handles + // the exact size (incl. any OBS rows) and build_content re-anchors each frame + const float line_h = ImGui::GetTextLineHeight(); + const float label_w = (std::max)( + ImGui::CalcTextSize("Time").x, + ImGui::CalcTextSize("Game").x); + const float value_w = ImGui::CalcTextSize("00:00:00").x; + + const float win_w = label_w + value_w + + FPS_CELL_PADDING.x * 2 + + FPS_WINDOW_PADDING.x * 2; + const float win_h = (line_h + FPS_CELL_PADDING.y * 2) * 3 + + FPS_WINDOW_PADDING.y * 2; + this->init_size = ImVec2(win_w, win_h); + this->init_pos = this->anchored_pos(this->init_size); } void FPS::build_content() { @@ -79,6 +81,21 @@ namespace overlay::windows { const auto uptime = now_s - this->start_time; + // OBS status (only adds rows while streaming live or recording/paused) + OBSStatus obs_status; + bool show_stream = false; + bool show_record = false; + if (auto *obs = static_cast(this->overlay->window_obs)) { + obs_status = obs->get_status(); + show_stream = obs_status.streaming; + show_record = obs_status.recording; + } + + // AlwaysAutoResize sizes the window to its content, so adding/removing OBS + // rows never clips; just re-anchor it to the configured corner each frame + // using the actual (auto-sized) dimensions + ImGui::SetWindowPos(this->anchored_pos(ImGui::GetWindowSize()), ImGuiCond_Always); + // right-align a label within the current cell so the label column reads // flush against the value column instead of looking ragged. the label is // only slightly dimmer than normal text (not the much darker "disabled" tone) @@ -117,6 +134,31 @@ namespace overlay::windows { fmt::format("{:%H:%M:%S}", std::chrono::floor(uptime)).c_str()); + // OBS rows - only present while live or recording + const ImVec4 col_red(0.90f, 0.30f, 0.30f, 1.0f); + const ImVec4 col_yellow(0.95f, 0.80f, 0.30f, 1.0f); + if (show_stream) { + const int64_t ms = OBSControl::live_duration_ms( + obs_status.stream_duration_ms, obs_status.stream_duration_base_tick, true); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + label("Live"); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(col_red, "%s", + fmt::format("{:%H:%M:%S}", std::chrono::seconds(ms / 1000)).c_str()); + } + if (show_record) { + const int64_t ms = OBSControl::live_duration_ms( + obs_status.record_duration_ms, obs_status.record_duration_base_tick, + !obs_status.record_paused); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + label("Rec"); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(obs_status.record_paused ? col_yellow : col_red, "%s", + fmt::format("{:%H:%M:%S}", std::chrono::seconds(ms / 1000)).c_str()); + } + ImGui::EndTable(); } ImGui::PopStyleVar(); diff --git a/src/spice2x/overlay/windows/fps.h b/src/spice2x/overlay/windows/fps.h index 67d74cd..1ba52e4 100644 --- a/src/spice2x/overlay/windows/fps.h +++ b/src/spice2x/overlay/windows/fps.h @@ -9,6 +9,9 @@ namespace overlay::windows { private: std::chrono::system_clock::time_point start_time; + // anchored top-left position for a window of the given size, per FPS_LOCATION + ImVec2 anchored_pos(const ImVec2 &size) const; + public: FPS(SpiceOverlay *overlay); diff --git a/src/spice2x/overlay/windows/obs.cpp b/src/spice2x/overlay/windows/obs.cpp new file mode 100644 index 0000000..f808e78 --- /dev/null +++ b/src/spice2x/overlay/windows/obs.cpp @@ -0,0 +1,290 @@ +#include "obs.h" + +#include +#include +#include + +#include "external/imgui/imgui.h" + +#include "games/io.h" +#include "overlay/overlay.h" +#include "overlay/imgui/extensions.h" + +using namespace std::chrono; + +// OBS WebSocket protocol/worker thread lives in obs_websocket.cpp; this file +// owns the ImGui control window and the connection lifecycle. + +namespace { + + // status text colors + const ImVec4 COL_GREEN(0.40f, 0.85f, 0.40f, 1.0f); + const ImVec4 COL_RED(0.90f, 0.30f, 0.30f, 1.0f); + const ImVec4 COL_YELLOW(0.95f, 0.80f, 0.30f, 1.0f); + const ImVec4 COL_GREY(0.60f, 0.60f, 0.60f, 1.0f); + + // muted action-button fills (start = green, stop = red, pause = yellow); the + // hovered/active shades are derived by brightening the base + const ImVec4 COL_BTN_GREEN(0.20f, 0.45f, 0.24f, 1.0f); + const ImVec4 COL_BTN_RED(0.52f, 0.20f, 0.20f, 1.0f); + const ImVec4 COL_BTN_YELLOW(0.52f, 0.42f, 0.16f, 1.0f); + + // an in-flight request lingers for at most this long before the button frees + // itself, so a dropped state event can never wedge a control permanently + const int64_t PENDING_TIMEOUT_MS = 5000; + + int64_t now_tick_ms() { + return duration_cast(steady_clock::now().time_since_epoch()).count(); + } + + std::string format_duration(int64_t ms) { + if (ms < 0) { + ms = 0; + } + const int64_t total_seconds = ms / 1000; + const int64_t hours = total_seconds / 3600; + const int64_t minutes = (total_seconds % 3600) / 60; + const int64_t seconds = total_seconds % 60; + char buf[16]; + snprintf(buf, sizeof(buf), "%02lld:%02lld:%02lld", + static_cast(hours), + static_cast(minutes), + static_cast(seconds)); + return buf; + } +} + +namespace overlay::windows { + + OBSControl::OBSControl(SpiceOverlay *overlay) : Window(overlay) { + this->title = "OBS Control"; + this->flags |= ImGuiWindowFlags_AlwaysAutoResize; + this->init_pos = overlay::apply_scaling_to_vector(120, 120); + this->toggle_button = games::OverlayButtons::ToggleOBSControl; + + this->worker_running.store(true); + this->worker_thread = std::thread(&OBSControl::worker_main, this); + } + + OBSControl::~OBSControl() { + // signal stop and wake any in-progress interruptible_sleep at once; the + // lock around the store pairs with the wait predicate to avoid a lost wakeup + { + std::lock_guard lock(this->worker_mutex); + this->worker_running.store(false); + } + this->worker_cv.notify_all(); + if (this->worker_thread.joinable()) { + // note: if the worker is mid-connect, WebSocket::from_url performs a + // blocking getaddrinfo/connect that does not observe worker_running, + // so this join can stall for the OS connect timeout. the default + // 127.0.0.1 host fails fast (connection refused); only a misconfigured + // unreachable remote OBS_CONTROL_HOST would delay shutdown here. + this->worker_thread.join(); + } + } + + OBSStatus OBSControl::get_status() { + std::lock_guard lock(this->status_mutex); + return this->status; + } + + int64_t OBSControl::live_duration_ms(int64_t base_ms, int64_t base_tick, bool ticking) { + if (!ticking) { + return (std::max)(base_ms, 0); + } + const int64_t now = + duration_cast(steady_clock::now().time_since_epoch()).count(); + // clamp so a stale base tick / clock hiccup can never yield a negative + // duration; callers (FPS rows, build_content) format this directly + return (std::max)(base_ms + (now - base_tick), 0); + } + + void OBSControl::build_content() { + + const OBSStatus s = this->get_status(); + + // label + colored value on a single line + const auto status_line = [](const char *label, const ImVec4 &col, const char *value) { + ImGui::Text("%s", label); + ImGui::SameLine(); + ImGui::TextColored(col, "%s", value); + }; + + if (!s.connected) { + if (s.disabled) { + ImGui::TextColored(COL_GREY, "%s", "OBS Control is disabled"); + return; + } + if (s.identifying) { + status_line("OBS WebSocket:", COL_YELLOW, "Connecting..."); + } else { + status_line("OBS WebSocket:", COL_GREY, "Not connected"); + } + const std::string url = + "ws://" + OBS_CONTROL_HOST + ":" + std::to_string(OBS_CONTROL_PORT); + status_line("Address:", COL_GREY, url.c_str()); + if (!s.connection_error.empty()) { + ImGui::TextColored(COL_RED, "%s", s.connection_error.c_str()); + } + return; + } + + status_line("OBS WebSocket:", COL_GREEN, "Connected"); + + // one fixed content width drives the whole panel so it never resizes as + // the scene name or button labels change; every row is sized to fit it + const float spacing = ImGui::GetStyle().ItemSpacing.x; + const float row_w = overlay::apply_scaling(240); + + if (s.current_scene.empty()) { + status_line("Scene:", COL_GREY, "(unknown)"); + } else { + ImGui::Text("Scene:"); + ImGui::SameLine(); + // truncate to the remaining row width so "Scene:" + value together + // never overflow and push the window wider + const float label_w = ImGui::CalcTextSize("Scene:").x; + ImGui::PushStyleColor(ImGuiCol_Text, COL_GREY); + ImGui::TextTruncated(s.current_scene, row_w - label_w - spacing); + ImGui::PopStyleColor(); + } + + ImGui::Separator(); + + const int64_t now = now_tick_ms(); + // every button shares one fixed size; two side-by-side fill the row width, + // single buttons keep that same size rather than stretching to fill + const ImVec2 btn((row_w - spacing) * 0.5f, 0); + + // has OBS reached the state a pending action was waiting for? + const auto reached = [&](OBSAction a) { + switch (a) { + case OBSAction::StreamStart: return s.streaming; + case OBSAction::StreamStop: return !s.streaming; + case OBSAction::RecordStart: return s.recording; + case OBSAction::RecordStop: return !s.recording; + case OBSAction::RecordPause: return s.record_paused; + case OBSAction::RecordResume: return !s.record_paused; + default: return true; + } + }; + + // drop a pending action once OBS confirms the new state, or once the + // safety deadline lapses (so a dropped event can't wedge the button) + const auto settle = [&](OBSAction &slot, int64_t deadline) { + if (slot != OBSAction::None && (reached(slot) || now >= deadline)) { + slot = OBSAction::None; + } + }; + settle(this->stream_pending, this->stream_pending_deadline); + settle(this->record_pending, this->record_pending_deadline); + + // a colored button that fires a request and marks the output busy on click + const auto action_button = + [&](const char *label, + const ImVec4 &color, + const char *request, + OBSAction &slot, + int64_t &deadline, + OBSAction action) { + + if (ImGui::ColoredButton(label, color, btn)) { + enqueue_request(request); + slot = action; + deadline = now + PENDING_TIMEOUT_MS; + } + }; + + // streaming + { + const bool pending = this->stream_pending != OBSAction::None; + + if (s.streaming) { + const int64_t ms = live_duration_ms( + s.stream_duration_ms, s.stream_duration_base_tick, true); + status_line("Streaming:", COL_RED, ("LIVE " + format_duration(ms)).c_str()); + } else { + status_line("Streaming:", COL_GREY, pending ? "Starting..." : "Idle"); + } + + ImGui::BeginDisabled(pending); + if (s.streaming) { + action_button( + pending ? "Stopping...##stream" : "Stop Streaming##stream", + COL_BTN_RED, + "StopStream", + this->stream_pending, + this->stream_pending_deadline, + OBSAction::StreamStop); + } else { + action_button( + pending ? "Starting...##stream" : "Start Streaming##stream", + COL_BTN_GREEN, + "StartStream", + this->stream_pending, + this->stream_pending_deadline, + OBSAction::StreamStart); + } + ImGui::EndDisabled(); + } + + ImGui::Separator(); + + // recording + { + const bool pending = this->record_pending != OBSAction::None; + + if (!s.recording) { + status_line("Recording:", COL_GREY, pending ? "Starting..." : "Idle"); + ImGui::BeginDisabled(pending); + action_button( + pending ? "Starting...##record" : "Start Recording##record", + COL_BTN_GREEN, + "StartRecord", + this->record_pending, + this->record_pending_deadline, + OBSAction::RecordStart); + ImGui::EndDisabled(); + return; + } + + const int64_t ms = live_duration_ms( + s.record_duration_ms, s.record_duration_base_tick, !s.record_paused); + if (s.record_paused) { + status_line("Recording:", COL_YELLOW, ("PAUSED " + format_duration(ms)).c_str()); + } else { + status_line("Recording:", COL_RED, ("REC " + format_duration(ms)).c_str()); + } + + ImGui::BeginDisabled(pending); + action_button( + this->record_pending == OBSAction::RecordStop ? "Stopping...##record" : "Stop Recording##record", + COL_BTN_RED, + "StopRecord", + this->record_pending, + this->record_pending_deadline, OBSAction::RecordStop); + + ImGui::SameLine(); + if (s.record_paused) { + action_button( + this->record_pending == OBSAction::RecordResume ? "Resuming...##record_toggle" : "Resume##record_toggle", + COL_BTN_GREEN, + "ResumeRecord", + this->record_pending, + this->record_pending_deadline, + OBSAction::RecordResume); + + } else { + action_button( + this->record_pending == OBSAction::RecordPause ? "Pausing...##record_toggle" : "Pause##record_toggle", + COL_BTN_YELLOW, + "PauseRecord", + this->record_pending, + this->record_pending_deadline, + OBSAction::RecordPause); + } + ImGui::EndDisabled(); + } + } +} diff --git a/src/spice2x/overlay/windows/obs.h b/src/spice2x/overlay/windows/obs.h new file mode 100644 index 0000000..cfbe2f2 --- /dev/null +++ b/src/spice2x/overlay/windows/obs.h @@ -0,0 +1,131 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "external/rapidjson/fwd.h" +#include "overlay/window.h" + +namespace easywsclient { + class WebSocket; +} + +namespace overlay::windows { + + // OBS WebSocket connection settings, resolved once at launch from the merged + // launcher options (command line + saved config) following the same pattern + // as the other global launch settings in launcher.cpp + extern bool OBS_CONTROL_ENABLED; + extern std::string OBS_CONTROL_HOST; + extern uint16_t OBS_CONTROL_PORT; + extern std::string OBS_CONTROL_PASSWORD; + + // when true, easywsclient's internal diagnostics are routed to the logger + extern bool OBS_CONTROL_DEBUG; + + // status snapshot shared between the OBS worker thread and the render thread + struct OBSStatus { + bool disabled = true; + bool connected = false; + bool identifying = false; + std::string connection_error; + + // name of the active program scene (read-only, from obs-websocket) + std::string current_scene; + + bool streaming = false; + bool recording = false; + bool record_paused = false; + + // duration base values (milliseconds) and the local timestamp (ms since + // steady epoch) at which they were last refreshed, so the UI can tick a + // smooth timer between polls + int64_t stream_duration_ms = 0; + int64_t record_duration_ms = 0; + int64_t stream_duration_base_tick = 0; + int64_t record_duration_base_tick = 0; + }; + + // in-flight user action used purely for UI feedback: when the user clicks a + // control we remember what we asked for so the button can show a transitional + // label and stay disabled until the observed OBS state matches the request + // (or a short deadline lapses). owned solely by the render thread. + enum class OBSAction { + None, + StreamStart, StreamStop, + RecordStart, RecordStop, + RecordPause, RecordResume, + }; + + class OBSControl : public Window { + public: + OBSControl(SpiceOverlay *overlay); + ~OBSControl() override; + + void build_content() override; + + // thread-safe snapshot of the current status for external widgets (e.g. FPS) + OBSStatus get_status(); + + // live (ticked) duration in ms from a base value/tick captured at last poll + static int64_t live_duration_ms(int64_t base_ms, int64_t base_tick, bool ticking); + + private: + // worker thread entry + helpers (implementation owns the WebSocket) + void worker_main(); + + // run one connected session loop until the socket closes or we stop; + // returns true if the obs-websocket handshake reached "Identified", false + // if the socket closed first (e.g. OBS rejected our auth) + bool run_session(easywsclient::WebSocket *ws, const std::string &password, + uint64_t &request_id); + + // handle a single inbound obs-websocket message (parses + dispatches) + void handle_message(easywsclient::WebSocket *ws, const std::string &message, + const std::string &password, uint64_t &request_id, + bool &identified); + + // per-opcode handlers dispatched from handle_message + using request_fn = std::function; + void handle_identified(bool &identified, const request_fn &request); + void handle_event(const rapidjson::Value &d, const request_fn &request); + void handle_response(const rapidjson::Value &d); + + void enqueue_request(const std::string &request_type); + + // sleep up to total_ms, waking early if the worker is asked to stop + void interruptible_sleep(int total_ms); + + // worker thread + std::thread worker_thread; + std::atomic worker_running { false }; + + // wakes interruptible_sleep immediately when worker_running is cleared, + // so shutdown (and the reconnect backoff) never waits out a fixed delay + std::mutex worker_mutex; + std::condition_variable worker_cv; + + // shared status (guarded by status_mutex) + std::mutex status_mutex; + OBSStatus status; + + // outgoing user commands (guarded by command_mutex) + std::mutex command_mutex; + std::deque command_queue; + + // transient action feedback, touched only by the render thread (no sync): + // remembers the last start/stop/pause request per output so the button can + // show a "Starting.../Stopping..." label and stay disabled until OBS reports + // the matching state, with *_deadline as a fallback if the update is missed + OBSAction stream_pending = OBSAction::None; + OBSAction record_pending = OBSAction::None; + int64_t stream_pending_deadline = 0; + int64_t record_pending_deadline = 0; + }; +} diff --git a/src/spice2x/overlay/windows/obs_websocket.cpp b/src/spice2x/overlay/windows/obs_websocket.cpp new file mode 100644 index 0000000..c9c9cab --- /dev/null +++ b/src/spice2x/overlay/windows/obs_websocket.cpp @@ -0,0 +1,434 @@ +#include + +#include "obs.h" + +#include + +#include "external/easywsclient/easywsclient.hpp" +#include "external/rapidjson/document.h" +#include "external/rapidjson/stringbuffer.h" +#include "external/rapidjson/writer.h" +#include "external/hash-library/sha256.h" + +#include "overlay/notifications.h" +#include "util/crypt.h" +#include "util/logging.h" + +// defined in easywsclient.cpp; gates its internal diagnostic output +extern bool EASYWSCLIENT_LOGGING_ENABLED; + +using easywsclient::WebSocket; +using namespace std::chrono; + +// obs-websocket v5 message flow (https://github.com/obsproject/obs-websocket): +// server -> op 0 Hello (may include an auth challenge) +// client -> op 1 Identify (answers the challenge, picks rpcVersion) +// server -> op 2 Identified (handshake done; requests may now be sent) +// server -> op 5 Event (state changes: stream/record/scene/...) +// client -> op 6 Request (e.g. GetStreamStatus, StartRecord) +// server -> op 7 RequestResponse (reply to a Request, carries responseData) +// Every message is { "op": , "d": { ... } }. Event fields are nested under +// d["eventData"] and request replies under d["responseData"], not in d directly. + +namespace { + + int64_t now_ms() { + return duration_cast(steady_clock::now().time_since_epoch()).count(); + } + + // raw SHA256 digest -> base64 (obs-websocket v5 auth primitive) + std::string sha256_base64(const std::string &input) { + SHA256 hasher; + hasher.add(input.data(), input.size()); + unsigned char digest[SHA256::HashBytes]; + hasher.getHash(digest); + return crypt::base64_encode(reinterpret_cast(digest), SHA256::HashBytes); + } + + // auth = base64(sha256(base64(sha256(password + salt)) + challenge)) + std::string compute_auth(const std::string &password, const std::string &salt, + const std::string &challenge) { + const std::string secret = sha256_base64(password + salt); + return sha256_base64(secret + challenge); + } + + std::string build_identify(int rpc_version, const std::string &authentication) { + rapidjson::StringBuffer sb; + rapidjson::Writer w(sb); + w.StartObject(); + w.Key("op"); w.Int(1); + w.Key("d"); + w.StartObject(); + w.Key("rpcVersion"); w.Int(rpc_version); + if (!authentication.empty()) { + w.Key("authentication"); w.String(authentication.c_str()); + } + w.EndObject(); + w.EndObject(); + return sb.GetString(); + } + + std::string build_request(const std::string &request_type, uint64_t request_id) { + rapidjson::StringBuffer sb; + rapidjson::Writer w(sb); + w.StartObject(); + w.Key("op"); w.Int(6); + w.Key("d"); + w.StartObject(); + w.Key("requestType"); w.String(request_type.c_str()); + w.Key("requestId"); w.String(std::to_string(request_id).c_str()); + w.EndObject(); + w.EndObject(); + return sb.GetString(); + } + + // read a numeric field as int64 ms (obs sends durations as integers/doubles) + int64_t json_number(const rapidjson::Value &obj, const char *key) { + if (obj.HasMember(key) && obj[key].IsNumber()) { + return static_cast(obj[key].GetDouble()); + } + return 0; + } + + bool json_bool(const rapidjson::Value &obj, const char *key) { + return obj.HasMember(key) && obj[key].IsBool() && obj[key].GetBool(); + } + + std::string json_string(const rapidjson::Value &obj, const char *key) { + if (obj.HasMember(key) && obj[key].IsString()) { + return obj[key].GetString(); + } + return ""; + } + + // build the Identify (op 1) reply to a Hello (op 0), answering the auth + // challenge if the server requires one + std::string build_hello_response(const rapidjson::Value &d, const std::string &password) { + int rpc_version = 1; + if (d.HasMember("rpcVersion") && d["rpcVersion"].IsInt()) { + rpc_version = d["rpcVersion"].GetInt(); + } + std::string auth; + if (d.HasMember("authentication") && d["authentication"].IsObject()) { + const rapidjson::Value &a = d["authentication"]; + const std::string challenge = json_string(a, "challenge"); + const std::string salt = json_string(a, "salt"); + if (!challenge.empty()) { + auth = compute_auth(password, salt, challenge); + } + } + return build_identify(rpc_version, auth); + } + + // map an obs-websocket outputState to a user notification. `label` is the + // output kind ("Streaming" or "Recording"). transitional states are ignored. + void notify_output_state(const char *label, const std::string &state) { + using overlay::notifications::Severity; + + struct StateToast { + const char *state; + Severity severity; + const char *verb; + }; + static const StateToast TOASTS[] = { + { "OBS_WEBSOCKET_OUTPUT_STARTED", Severity::Success, "started" }, + { "OBS_WEBSOCKET_OUTPUT_STOPPED", Severity::Info, "stopped" }, + { "OBS_WEBSOCKET_OUTPUT_PAUSED", Severity::Warning, "paused" }, + { "OBS_WEBSOCKET_OUTPUT_RESUMED", Severity::Info, "resumed" }, + }; + + for (const auto &toast : TOASTS) { + if (state == toast.state) { + overlay::notifications::add(toast.severity, + "OBS: " + std::string(label) + " " + toast.verb); + return; + } + } + } +} + +namespace overlay::windows { + + // connection settings resolved at launch (see launcher.cpp) + bool OBS_CONTROL_ENABLED = false; + std::string OBS_CONTROL_HOST = "127.0.0.1"; + uint16_t OBS_CONTROL_PORT = 4455; + std::string OBS_CONTROL_PASSWORD; + bool OBS_CONTROL_DEBUG = false; + + void OBSControl::enqueue_request(const std::string &request_type) { + std::lock_guard lock(this->command_mutex); + this->command_queue.push_back(request_type); + } + + void OBSControl::interruptible_sleep(int total_ms) { + std::unique_lock lock(this->worker_mutex); + this->worker_cv.wait_for(lock, milliseconds(total_ms), + [this] { return !this->worker_running.load(); }); + } + + void OBSControl::handle_message(WebSocket *ws, const std::string &message, + const std::string &password, uint64_t &request_id, bool &identified) { + + rapidjson::Document doc; + if (doc.Parse(message.c_str()).HasParseError() || !doc.IsObject()) { + return; + } + if (!doc.HasMember("op") || !doc["op"].IsInt() + || !doc.HasMember("d") || !doc["d"].IsObject()) { + return; + } + const int op = doc["op"].GetInt(); + const rapidjson::Value &d = doc["d"]; + + // send an op 6 Request; each needs a unique id (we never match replies + // back, so a simple incrementing counter is enough) + const request_fn request = [&](const char *request_type) { + ws->send(build_request(request_type, ++request_id)); + }; + + switch (op) { + case 0: // Hello + // server greeted us: reply with Identify, solving the auth + // challenge inline if the server set a password + ws->send(build_hello_response(d, password)); + break; + case 2: // Identified + this->handle_identified(identified, request); + break; + case 5: // Event + this->handle_event(d, request); + break; + case 7: // RequestResponse + this->handle_response(d); + break; + default: + break; + } + } + + void OBSControl::handle_identified(bool &identified, const request_fn &request) { + // handshake complete: the connection is now usable for requests + identified = true; + { + std::lock_guard lock(this->status_mutex); + this->status.connected = true; + this->status.identifying = false; + this->status.connection_error.clear(); + } + log_info("obs", "connected and identified"); + + // pull the current scene/stream/record state so the UI starts accurate + request("GetCurrentProgramScene"); + request("GetStreamStatus"); + request("GetRecordStatus"); + } + + void OBSControl::handle_event(const rapidjson::Value &d, const request_fn &request) { + const std::string type = json_string(d, "eventType"); + const bool has_data = d.HasMember("eventData") && d["eventData"].IsObject(); + + if (type == "StreamStateChanged") { + if (has_data) { + notify_output_state("Streaming", json_string(d["eventData"], "outputState")); + } + request("GetStreamStatus"); + } else if (type == "RecordStateChanged") { + if (has_data) { + notify_output_state("Recording", json_string(d["eventData"], "outputState")); + } + request("GetRecordStatus"); + } else if (type == "CurrentProgramSceneChanged" && has_data) { + std::lock_guard lock(this->status_mutex); + this->status.current_scene = json_string(d["eventData"], "sceneName"); + } + } + + void OBSControl::handle_response(const rapidjson::Value &d) { + const std::string type = json_string(d, "requestType"); + if (type.empty() || !d.HasMember("responseData") || !d["responseData"].IsObject()) { + return; + } + const rapidjson::Value &rd = d["responseData"]; + + std::lock_guard lock(this->status_mutex); + if (type == "GetCurrentProgramScene") { + // newer obs returns sceneName; older builds used the now-deprecated + // currentProgramSceneName, so prefer it then fall back + std::string scene = json_string(rd, "currentProgramSceneName"); + if (scene.empty()) { + scene = json_string(rd, "sceneName"); + } + this->status.current_scene = scene; + } else if (type == "GetStreamStatus") { + this->status.streaming = json_bool(rd, "outputActive"); + this->status.stream_duration_ms = json_number(rd, "outputDuration"); + this->status.stream_duration_base_tick = now_ms(); + } else if (type == "GetRecordStatus") { + this->status.recording = json_bool(rd, "outputActive"); + this->status.record_paused = json_bool(rd, "outputPaused"); + this->status.record_duration_ms = json_number(rd, "outputDuration"); + this->status.record_duration_base_tick = now_ms(); + } + } + + bool OBSControl::run_session(WebSocket *ws, const std::string &password, uint64_t &request_id) { + + // one iteration of a live connection: pump socket I/O, dispatch any + // inbound messages, flush queued user commands, then refresh status + bool identified = false; + // handle_identified() issues the first GetStreamStatus/GetRecordStatus on + // identify, so the periodic poll below just maintains the ~1s cadence + auto last_status_poll = steady_clock::now(); + + // send a request with the next sequential id + const auto request = [&](const char *request_type) { + ws->send(build_request(request_type, ++request_id)); + }; + + while (this->worker_running.load() && ws->getReadyState() != WebSocket::CLOSED) { + ws->poll(100); + + ws->dispatch([&](const std::string &message) { + this->handle_message(ws, message, password, request_id, identified); + }); + + if (ws->getReadyState() == WebSocket::CLOSED) { + break; + } + + // nothing may be sent until the op 2 Identified handshake completes + if (!identified) { + continue; + } + + // drain user commands + std::deque pending; + { + std::lock_guard lock(this->command_mutex); + pending.swap(this->command_queue); + } + for (const auto &cmd : pending) { + ws->send(build_request(cmd, ++request_id)); + } + + // periodic status refresh (~1s) for live duration + const auto now = steady_clock::now(); + if (now - last_status_poll >= milliseconds(1000)) { + last_status_poll = now; + request("GetStreamStatus"); + request("GetRecordStatus"); + } + } + + return identified; + } + + void OBSControl::worker_main() { + + // connection settings are resolved once at launch into globals + // (launcher.cpp, from the merged command-line + saved config options) + if (!OBS_CONTROL_ENABLED) { + log_info("obs", "disabled, not connecting"); + std::lock_guard lock(this->status_mutex); + this->status.disabled = true; + return; + } + + const std::string url = "ws://" + OBS_CONTROL_HOST + ":" + std::to_string(OBS_CONTROL_PORT); + const std::string password = OBS_CONTROL_PASSWORD; + + // opt easywsclient's internal diagnostics in/out per the debug option + EASYWSCLIENT_LOGGING_ENABLED = OBS_CONTROL_DEBUG; + + // winsock is reference-counted: the app performs its own WSAStartup at + // launch (which outlives this worker), so this paired Startup/Cleanup only + // bumps the refcount and the WSACleanup below never tears down winsock for + // the rest of the process + WSADATA wsa_data; + WSAStartup(MAKEWORD(2, 2), &wsa_data); + log_info("obs", "enabled, connecting to {}", url); + + uint64_t request_id = 0; + + // the reconnect loop retries every 5s; latch the auth-failure warning so a + // wrong password logs once, not on every retry. reset after any identified + // session so a later genuine failure is reported again + bool auth_warning_logged = false; + + // reconnect loop: keep a session alive while enabled, retrying on drop + while (this->worker_running.load()) { + + // mark "connecting" for the UI before each attempt + { + std::lock_guard lock(this->status_mutex); + this->status.disabled = false; + this->status.connected = false; + this->status.identifying = true; + this->status.connection_error.clear(); + } + + // open the TCP socket and perform the WebSocket handshake; null means + // OBS is unreachable (not running / wrong port / obs-websocket off) + WebSocket::pointer ws = WebSocket::from_url(url); + if (ws == nullptr) { + { + std::lock_guard lock(this->status_mutex); + this->status.identifying = false; + this->status.connection_error = "Unable to connect"; + } + interruptible_sleep(5000); + continue; + } + + // blocks here pumping the connection until it closes or we stop. + // a session that never reaches "Identified" was rejected by OBS, + // overwhelmingly because the password is wrong or missing + const bool identified = this->run_session(ws, password, request_id); + + // session ended: close the socket cleanly and free it + ws->close(); + ws->poll(); + delete ws; + + if (!identified && this->worker_running.load()) { + if (!auth_warning_logged) { + log_warning("obs", "connection closed before identify; " + "OBS likely rejected authentication (check the password)"); + auth_warning_logged = true; + } + } else if (identified) { + // a good session resets the latch so a future failure logs again + auth_warning_logged = false; + } + + // connection dropped: clear live state so the UI doesn't show stale + // scene/stream/record info while disconnected + { + std::lock_guard lock(this->status_mutex); + this->status.connected = false; + this->status.identifying = false; + if (this->status.connection_error.empty()) { + this->status.connection_error = + identified ? "Disconnected" : "Auth failed (check password)"; + } + this->status.streaming = false; + this->status.recording = false; + this->status.record_paused = false; + this->status.current_scene.clear(); + } + + // clear any commands queued while disconnected + { + std::lock_guard lock(this->command_mutex); + this->command_queue.clear(); + } + + // wait before reconnecting (interruptible) + interruptible_sleep(5000); + } + + WSACleanup(); + log_info("obs", "OBS overlay worker stopped"); + } +}