#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"); } }