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
This commit is contained in:
bicarus
2026-06-24 02:44:36 -07:00
committed by GitHub
parent 5c69e295ab
commit 451cbec0b9
19 changed files with 1680 additions and 25 deletions
+14
View File
@@ -1,4 +1,5 @@
#include "extensions.h"
#include <algorithm>
#include <cmath>
#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
+5
View File
@@ -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);
+7
View File
@@ -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);
+1
View File
@@ -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.
+3 -2
View File
@@ -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);
+64 -22
View File
@@ -1,6 +1,7 @@
#include <algorithm>
#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::seconds>(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<OBSControl *>(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<std::chrono::seconds>(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();
+3
View File
@@ -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);
+290
View File
@@ -0,0 +1,290 @@
#include "obs.h"
#include <algorithm>
#include <chrono>
#include <cstdio>
#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<milliseconds>(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<long long>(hours),
static_cast<long long>(minutes),
static_cast<long long>(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<std::mutex> 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<std::mutex> 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<int64_t>)(base_ms, 0);
}
const int64_t now =
duration_cast<milliseconds>(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<int64_t>)(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();
}
}
}
+131
View File
@@ -0,0 +1,131 @@
#pragma once
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#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(const char *request_type)>;
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<bool> 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<std::string> 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;
};
}
@@ -0,0 +1,434 @@
#include <winsock2.h>
#include "obs.h"
#include <chrono>
#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": <int>, "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<milliseconds>(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<const uint8_t *>(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<rapidjson::StringBuffer> 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<rapidjson::StringBuffer> 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<int64_t>(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<std::mutex> lock(this->command_mutex);
this->command_queue.push_back(request_type);
}
void OBSControl::interruptible_sleep(int total_ms) {
std::unique_lock<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::string> pending;
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(this->command_mutex);
this->command_queue.clear();
}
// wait before reconnecting (interruptible)
interruptible_sleep(5000);
}
WSACleanup();
log_info("obs", "OBS overlay worker stopped");
}
}