From bbc1f724e1bb662903db67359810887c5e0d8cc7 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 10 Feb 2026 06:34:17 -0700 Subject: [PATCH 01/20] Phase 1 --- src/config.cpp | 4 ++-- src_assets/common/assets/web/config.html | 4 ++-- src_assets/common/assets/web/configs/tabs/Inputs.vue | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/config.cpp b/src/config.cpp index c320ed6d..cc7b704b 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -556,8 +556,8 @@ namespace config { true, // client gamepads with touchpads are emulated as DS4 true, // ds5_inputtino_randomize_mac - true, // keyboard enabled - true, // mouse enabled + false, // keyboard disabled (Parsec-style default) + false, // mouse disabled (Parsec-style default) true, // controller enabled true, // always send scancodes true, // high resolution scrolling diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index 222fba0e..1bc7cdb6 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -203,12 +203,12 @@ "touchpad_as_ds4": "enabled", "ds5_inputtino_randomize_mac": "enabled", "back_button_timeout": -1, - "keyboard": "enabled", + "keyboard": "disabled", "key_repeat_delay": 500, "key_repeat_frequency": 24.9, "always_send_scancodes": "enabled", "key_rightalt_to_key_win": "disabled", - "mouse": "enabled", + "mouse": "disabled", "high_resolution_scrolling": "enabled", "native_pen_touch": "enabled", "keybindings": "[0x10,0xA0,0x11,0xA2,0x12,0xA4]", // todo: add this to UI diff --git a/src_assets/common/assets/web/configs/tabs/Inputs.vue b/src_assets/common/assets/web/configs/tabs/Inputs.vue index 7fa76a20..64792b07 100644 --- a/src_assets/common/assets/web/configs/tabs/Inputs.vue +++ b/src_assets/common/assets/web/configs/tabs/Inputs.vue @@ -118,7 +118,7 @@ const config = ref(props.config) id="keyboard" locale-prefix="config" v-model="config.keyboard" - default="true" + default="false" > @@ -161,7 +161,7 @@ const config = ref(props.config) id="mouse" locale-prefix="config" v-model="config.mouse" - default="true" + default="false" > From 0c16e913da08d83f6794c7f95e6477ae473da359 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 11 Feb 2026 13:44:02 -0700 Subject: [PATCH 02/20] Phase 2 --- src/config.cpp | 2 + src/config.h | 2 + src/confighttp.cpp | 15 +++ src/input.cpp | 42 +++++++ src/nvhttp.cpp | 9 ++ src/nvhttp.h | 2 + src/stream.cpp | 105 ++++++++++++++++++ src/stream.h | 9 ++ src_assets/common/assets/web/config.html | 1 + .../common/assets/web/configs/tabs/Inputs.vue | 9 ++ .../common/assets/web/troubleshooting.html | 81 ++++++++++++++ 11 files changed, 277 insertions(+) diff --git a/src/config.cpp b/src/config.cpp index cc7b704b..371e8b24 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1235,6 +1235,8 @@ namespace config { bool_f(vars, "high_resolution_scrolling", input.high_resolution_scrolling); bool_f(vars, "native_pen_touch", input.native_pen_touch); + list_string_f(vars, "owner_client_uuids", input.owner_client_uuids); + bool_f(vars, "notify_pre_releases", sunshine.notify_pre_releases); bool_f(vars, "system_tray", sunshine.system_tray); diff --git a/src/config.h b/src/config.h index e8d1594f..c984bce3 100644 --- a/src/config.h +++ b/src/config.h @@ -203,6 +203,8 @@ namespace config { bool high_resolution_scrolling; bool native_pen_touch; + + std::vector owner_client_uuids; }; namespace flag { diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 85d66077..f718b190 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -40,6 +40,7 @@ #include "nvhttp.h" #include "platform/common.h" #include "process.h" +#include "stream.h" #include "utility.h" #include "uuid.h" @@ -805,6 +806,19 @@ namespace confighttp { send_response(response, output_tree); } + void getActiveSessions(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + nlohmann::json output_tree; + output_tree["sessions"] = stream::get_active_sessions_info(); + output_tree["status"] = true; + send_response(response, output_tree); + } + /** * @brief Unpair a client. * @param response The HTTP response object. @@ -1454,6 +1468,7 @@ namespace confighttp { server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll; server.resource["^/api/clients/list$"]["GET"] = getClients; server.resource["^/api/clients/unpair$"]["POST"] = unpair; + server.resource["^/api/sessions/active$"]["GET"] = getActiveSessions; server.resource["^/api/apps/close$"]["POST"] = closeApp; server.resource["^/api/covers/upload$"]["POST"] = uploadCover; server.resource["^/api/covers/([0-9]+)$"]["GET"] = getCover; diff --git a/src/input.cpp b/src/input.cpp index 82c761af..bf4a1859 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -41,6 +41,18 @@ namespace input { #define DISABLE_LEFT_BUTTON_DELAY ((thread_pool_util::ThreadPool::task_id_t) 0x01) #define ENABLE_LEFT_BUTTON_DELAY nullptr + static uint64_t now_ms() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch() + ).count(); + } + + static inline void touch_activity(std::atomic *ts) { + if (ts) { + ts->store(now_ms(), std::memory_order_relaxed); + } + } + constexpr auto VKEY_SHIFT = 0x10; constexpr auto VKEY_LSHIFT = 0xA0; constexpr auto VKEY_RSHIFT = 0xA1; @@ -193,6 +205,10 @@ namespace input { int32_t accumulated_vscroll_delta; int32_t accumulated_hscroll_delta; + + std::atomic *activity_keyboard_ms = nullptr; + std::atomic *activity_mouse_ms = nullptr; + std::atomic *activity_gamepad_ms = nullptr; }; /** @@ -446,6 +462,8 @@ namespace input { return; } + touch_activity(input->activity_mouse_ms); + input->mouse_left_button_timeout = DISABLE_LEFT_BUTTON_DELAY; platf::move_mouse(platf_input, util::endian::big(packet->deltaX), util::endian::big(packet->deltaY)); } @@ -543,6 +561,8 @@ namespace input { return; } + touch_activity(input->activity_mouse_ms); + if (input->mouse_left_button_timeout == DISABLE_LEFT_BUTTON_DELAY) { input->mouse_left_button_timeout = ENABLE_LEFT_BUTTON_DELAY; } @@ -593,6 +613,8 @@ namespace input { return; } + touch_activity(input->activity_mouse_ms); + auto release = util::endian::little(packet->header.magic) == MOUSE_BUTTON_UP_EVENT_MAGIC_GEN5; auto button = util::endian::big(packet->button); if (button > 0 && button < mouse_press.size()) { @@ -757,6 +779,8 @@ namespace input { return; } + touch_activity(input->activity_keyboard_ms); + auto release = util::endian::little(packet->header.magic) == KEY_UP_EVENT_MAGIC; auto keyCode = packet->keyCode & 0x00FF; @@ -817,6 +841,8 @@ namespace input { return; } + touch_activity(input->activity_mouse_ms); + if (config::input.high_resolution_scrolling) { platf::scroll(platf_input, util::endian::big(packet->scrollAmt1)); } else { @@ -840,6 +866,8 @@ namespace input { return; } + touch_activity(input->activity_mouse_ms); + if (config::input.high_resolution_scrolling) { platf::hscroll(platf_input, util::endian::big(packet->scrollAmount)); } else { @@ -872,6 +900,8 @@ namespace input { return; } + touch_activity(input->activity_gamepad_ms); + if (packet->controllerNumber < 0 || packet->controllerNumber >= input->gamepads.size()) { BOOST_LOG(warning) << "ControllerNumber out of range ["sv << packet->controllerNumber << ']'; return; @@ -912,6 +942,8 @@ namespace input { return; } + touch_activity(input->activity_mouse_ms); + // Convert the client normalized coordinates to touchport coordinates auto coords = client_to_touchport(input, {from_clamped_netfloat(packet->x, 0.0f, 1.0f) * 65535.f, from_clamped_netfloat(packet->y, 0.0f, 1.0f) * 65535.f}, {65535.f, 65535.f}); if (!coords) { @@ -968,6 +1000,8 @@ namespace input { return; } + touch_activity(input->activity_mouse_ms); + // Convert the client normalized coordinates to touchport coordinates auto coords = client_to_touchport(input, {from_clamped_netfloat(packet->x, 0.0f, 1.0f) * 65535.f, from_clamped_netfloat(packet->y, 0.0f, 1.0f) * 65535.f}, {65535.f, 65535.f}); if (!coords) { @@ -1026,6 +1060,8 @@ namespace input { return; } + touch_activity(input->activity_gamepad_ms); + if (packet->controllerNumber < 0 || packet->controllerNumber >= input->gamepads.size()) { BOOST_LOG(warning) << "ControllerNumber out of range ["sv << packet->controllerNumber << ']'; return; @@ -1059,6 +1095,8 @@ namespace input { return; } + touch_activity(input->activity_gamepad_ms); + if (packet->controllerNumber < 0 || packet->controllerNumber >= input->gamepads.size()) { BOOST_LOG(warning) << "ControllerNumber out of range ["sv << packet->controllerNumber << ']'; return; @@ -1091,6 +1129,8 @@ namespace input { return; } + touch_activity(input->activity_gamepad_ms); + if (packet->controllerNumber < 0 || packet->controllerNumber >= input->gamepads.size()) { BOOST_LOG(warning) << "ControllerNumber out of range ["sv << packet->controllerNumber << ']'; return; @@ -1116,6 +1156,8 @@ namespace input { return; } + touch_activity(input->activity_gamepad_ms); + if (packet->controllerNumber < 0 || packet->controllerNumber >= input->gamepads.size()) { BOOST_LOG(warning) << "ControllerNumber out of range ["sv << packet->controllerNumber << ']'; diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index 4d8a87b4..e624bcb3 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -782,6 +782,15 @@ namespace nvhttp { return named_cert_nodes; } + std::string get_client_name(const std::string &uuid) { + for (auto &named_cert : client_root.named_devices) { + if (named_cert.uuid == uuid) { + return named_cert.name; + } + } + return ""; + } + void applist(resp_https_t response, req_https_t request) { print_req(request); diff --git a/src/nvhttp.h b/src/nvhttp.h index 63633707..a0428ec7 100644 --- a/src/nvhttp.h +++ b/src/nvhttp.h @@ -193,6 +193,8 @@ namespace nvhttp { */ nlohmann::json get_all_clients(); + std::string get_client_name(const std::string &uuid); + /** * @brief Remove all paired clients. * @examples diff --git a/src/stream.cpp b/src/stream.cpp index b92e579e..11d87257 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -4,6 +4,7 @@ */ // standard includes +#include #include #include #include @@ -26,6 +27,7 @@ extern "C" { #include "input.h" #include "logging.h" #include "network.h" +#include "nvhttp.h" #include "platform/common.h" #include "process.h" #include "stream.h" @@ -340,6 +342,34 @@ namespace stream { control_server_t control_server; }; + /** + * @brief Per-session input policy (which remote input types are allowed). + */ + struct session_input_policy_t { + bool allow_gamepad = true; + bool allow_keyboard = false; + bool allow_mouse = false; + bool is_owner_session = false; + }; + + /** + * @brief Per-session input activity timestamps (monotonic ms). + */ + struct session_input_activity_t { + std::atomic last_keyboard_ms {0}; + std::atomic last_mouse_ms {0}; + std::atomic last_gamepad_ms {0}; + }; + + /** + * @brief Return current monotonic time in milliseconds. + */ + static uint64_t now_monotonic_ms() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch() + ).count(); + } + struct session_t { config_t config; @@ -347,6 +377,12 @@ namespace stream { std::shared_ptr input; + // Parsec-style per-session state + std::string client_unique_id; + std::string client_name; + session_input_policy_t input_policy; + session_input_activity_t input_activity; + std::thread audioThread; std::thread videoThread; @@ -1896,6 +1932,57 @@ namespace stream { audio::capture(session->mail, session->config.audio, session); } + nlohmann::json get_active_sessions_info() { + auto result = nlohmann::json::array(); + + auto ref = broadcast.ref(); + if (!ref) { + return result; + } + + auto now = now_monotonic_ms(); + + auto lg = ref->control_server._sessions.lock(); + for (auto *session : *ref->control_server._sessions) { + if (session->state.load(std::memory_order_relaxed) != session::state_e::RUNNING) { + continue; + } + + auto last_kb = session->input_activity.last_keyboard_ms.load(std::memory_order_relaxed); + auto last_mouse = session->input_activity.last_mouse_ms.load(std::memory_order_relaxed); + auto last_gp = session->input_activity.last_gamepad_ms.load(std::memory_order_relaxed); + + nlohmann::json entry; + entry["session_id"] = session->launch_session_id; + entry["client_uuid"] = session->client_unique_id; + entry["client_name"] = session->client_name; + entry["is_owner_session"] = session->input_policy.is_owner_session; + + entry["policy"] = { + {"allow_keyboard", session->input_policy.allow_keyboard}, + {"allow_mouse", session->input_policy.allow_mouse}, + {"allow_gamepad", session->input_policy.allow_gamepad}, + }; + + auto kb_ago = last_kb > 0 ? now - last_kb : UINT64_MAX; + auto mouse_ago = last_mouse > 0 ? now - last_mouse : UINT64_MAX; + auto gp_ago = last_gp > 0 ? now - last_gp : UINT64_MAX; + + entry["activity"] = { + {"keyboard_active", kb_ago <= KB_ACTIVE_WINDOW_MS}, + {"mouse_active", mouse_ago <= MOUSE_ACTIVE_WINDOW_MS}, + {"gamepad_active", gp_ago <= GAMEPAD_ACTIVE_WINDOW_MS}, + {"last_keyboard_ms_ago", last_kb > 0 ? kb_ago : -1}, + {"last_mouse_ms_ago", last_mouse > 0 ? mouse_ago : -1}, + {"last_gamepad_ms_ago", last_gp > 0 ? gp_ago : -1}, + }; + + result.push_back(std::move(entry)); + } + + return result; + } + namespace session { std::atomic_uint running_sessions; @@ -1964,6 +2051,10 @@ namespace stream { int start(session_t &session, const std::string &addr_string) { session.input = input::alloc(session.mail); + session.input->activity_keyboard_ms = &session.input_activity.last_keyboard_ms; + session.input->activity_mouse_ms = &session.input_activity.last_mouse_ms; + session.input->activity_gamepad_ms = &session.input_activity.last_gamepad_ms; + session.broadcast_ref = broadcast.ref(); if (!session.broadcast_ref) { return -1; @@ -2010,6 +2101,20 @@ namespace stream { session->shutdown_event = mail->event(mail::shutdown); session->launch_session_id = launch_session.id; + session->client_unique_id = launch_session.unique_id; + session->client_name = nvhttp::get_client_name(launch_session.unique_id); + + auto &owner_uuids = config::input.owner_client_uuids; + bool is_owner = std::find(owner_uuids.begin(), owner_uuids.end(), launch_session.unique_id) != owner_uuids.end(); + + session->input_policy.is_owner_session = is_owner; + session->input_policy.allow_gamepad = true; + session->input_policy.allow_keyboard = is_owner; + session->input_policy.allow_mouse = is_owner; + + if (is_owner) { + BOOST_LOG(info) << "Owner session detected for client: "sv << launch_session.unique_id; + } session->config = config; diff --git a/src/stream.h b/src/stream.h index 53afff4f..5126acaf 100644 --- a/src/stream.h +++ b/src/stream.h @@ -5,10 +5,13 @@ #pragma once // standard includes +#include #include +#include // lib includes #include +#include // local includes #include "audio.h" @@ -20,8 +23,14 @@ namespace stream { constexpr auto CONTROL_PORT = 10; constexpr auto AUDIO_STREAM_PORT = 11; + constexpr uint64_t KB_ACTIVE_WINDOW_MS = 500; + constexpr uint64_t MOUSE_ACTIVE_WINDOW_MS = 500; + constexpr uint64_t GAMEPAD_ACTIVE_WINDOW_MS = 500; + struct session_t; + nlohmann::json get_active_sessions_info(); + struct config_t { audio::config_t audio; video::config_t monitor; diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index 1bc7cdb6..fa7ca614 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -211,6 +211,7 @@ "mouse": "disabled", "high_resolution_scrolling": "enabled", "native_pen_touch": "enabled", + "owner_client_uuids": "", "keybindings": "[0x10,0xA0,0x11,0xA2,0x12,0xA4]", // todo: add this to UI }, }, diff --git a/src_assets/common/assets/web/configs/tabs/Inputs.vue b/src_assets/common/assets/web/configs/tabs/Inputs.vue index 64792b07..b7f188a5 100644 --- a/src_assets/common/assets/web/configs/tabs/Inputs.vue +++ b/src_assets/common/assets/web/configs/tabs/Inputs.vue @@ -181,6 +181,15 @@ const config = ref(props.config) v-model="config.native_pen_touch" default="true" > + +
+
+ + +
Comma-separated UUIDs of owner clients. Owner sessions start with keyboard/mouse enabled. Find UUIDs in the Troubleshooting page under paired clients.
+
diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index c33b3db2..96efa5d1 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -147,6 +147,49 @@ + +
+
+

Live Input Status

+

Per-session input activity and policy. Indicators update every second.

+
+
+ No active streaming sessions. +
+
    +
  • +
    +
    + {{ s.client_name || s.client_uuid || 'Unknown' }} + Owner +
    +
    + + + KB + + {{ s.policy.allow_keyboard ? 'ON' : 'OFF' }} + + + + + Mouse + + {{ s.policy.allow_mouse ? 'ON' : 'OFF' }} + + + + + Gamepad + + {{ s.policy.allow_gamepad ? 'ON' : 'OFF' }} + + +
    +
    +
  • +
+
@@ -229,6 +272,7 @@ }, data() { return { + activeSessions: [], clients: [], closeAppPressed: false, closeAppStatus: null, @@ -237,6 +281,7 @@ logs: 'Loading...', logFilter: null, logInterval: null, + sessionInterval: null, restartPressed: false, showApplyMessage: false, platform: "", @@ -389,13 +434,32 @@ this.logInterval = setInterval(() => { this.refreshLogs(); }, 5000); + this.sessionInterval = setInterval(() => { + this.refreshActiveSessions(); + }, 1000); this.refreshLogs(); this.refreshClients(); + this.refreshActiveSessions(); }, beforeDestroy() { clearInterval(this.logInterval); + clearInterval(this.sessionInterval); }, methods: { + refreshActiveSessions() { + fetch("./api/sessions/active") + .then((r) => r.json()) + .then((r) => { + if (r.status === true && r.sessions) { + this.activeSessions = r.sessions; + } else { + this.activeSessions = []; + } + }) + .catch(() => { + this.activeSessions = []; + }); + }, refreshLogs() { fetch("./api/logs",) .then((r) => r.text()) @@ -610,4 +674,21 @@ initApp(app); + + From 6c3a91357cec596986b9fc750a147b616c80e85a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 11 Feb 2026 13:59:42 -0700 Subject: [PATCH 03/20] Fix compilation --- src/input.cpp | 10 ++++++++++ src/input.h | 6 ++++++ src/stream.cpp | 8 +++++--- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/input.cpp b/src/input.cpp index bf4a1859..f57a28cb 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -1722,4 +1722,14 @@ namespace input { return input; } + + void set_activity_pointers( + std::shared_ptr &input, + std::atomic *keyboard_ms, + std::atomic *mouse_ms, + std::atomic *gamepad_ms) { + input->activity_keyboard_ms = keyboard_ms; + input->activity_mouse_ms = mouse_ms; + input->activity_gamepad_ms = gamepad_ms; + } } // namespace input diff --git a/src/input.h b/src/input.h index 5b564f04..82a52e6e 100644 --- a/src/input.h +++ b/src/input.h @@ -24,6 +24,12 @@ namespace input { std::shared_ptr alloc(safe::mail_t mail); + void set_activity_pointers( + std::shared_ptr &input, + std::atomic *keyboard_ms, + std::atomic *mouse_ms, + std::atomic *gamepad_ms); + struct touch_port_t: public platf::touch_port_t { int env_width, env_height; diff --git a/src/stream.cpp b/src/stream.cpp index 11d87257..4c6c7e9b 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -2051,9 +2051,11 @@ namespace stream { int start(session_t &session, const std::string &addr_string) { session.input = input::alloc(session.mail); - session.input->activity_keyboard_ms = &session.input_activity.last_keyboard_ms; - session.input->activity_mouse_ms = &session.input_activity.last_mouse_ms; - session.input->activity_gamepad_ms = &session.input_activity.last_gamepad_ms; + input::set_activity_pointers( + session.input, + &session.input_activity.last_keyboard_ms, + &session.input_activity.last_mouse_ms, + &session.input_activity.last_gamepad_ms); session.broadcast_ref = broadcast.ref(); if (!session.broadcast_ref) { From 0f070b144f0307d6da4a2f20848def31144a7f62 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 11 Feb 2026 14:39:37 -0700 Subject: [PATCH 04/20] Fix client UUID resolution from SSL peer certificate --- src/nvhttp.cpp | 57 +++++++++++++++++++++++++++++++++++ src/stream.cpp | 6 ++-- third-party/Simple-Web-Server | 2 +- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index e624bcb3..dff43e07 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -279,6 +279,53 @@ namespace nvhttp { } } + /** + * @brief Resolve the real paired client UUID from the SSL peer certificate. + * @param request The HTTPS request whose peer cert identifies the client. + * @return The paired client's UUID, or empty string if not found. + * + * Moonlight sends a placeholder uniqueid ("0123456789ABCDEF") in the launch + * query string. The real identity is the SSL client certificate presented + * during the TLS handshake, which we match against our paired device list. + */ + std::string resolve_client_uuid(req_https_t request) { + auto conn = request->connection_shared(); + if (!conn) { + BOOST_LOG(warning) << "resolve_client_uuid: connection expired"sv; + return {}; + } + + auto ssl = conn->socket->native_handle(); + if (!ssl) { + BOOST_LOG(warning) << "resolve_client_uuid: no SSL handle"sv; + return {}; + } + + crypto::x509_t peer_cert { +#if OPENSSL_VERSION_MAJOR >= 3 + SSL_get1_peer_certificate(ssl) +#else + SSL_get_peer_certificate(ssl) +#endif + }; + if (!peer_cert) { + BOOST_LOG(warning) << "resolve_client_uuid: no peer certificate"sv; + return {}; + } + + auto peer_pem = crypto::pem(peer_cert); + + for (const auto &device : client_root.named_devices) { + if (device.cert == peer_pem) { + BOOST_LOG(info) << "resolve_client_uuid: matched paired client '"sv << device.name << "' uuid="sv << device.uuid; + return device.uuid; + } + } + + BOOST_LOG(warning) << "resolve_client_uuid: peer cert did not match any paired device"sv; + return {}; + } + std::shared_ptr make_launch_session(bool host_audio, const args_t &args) { auto launch_session = std::make_shared(); @@ -868,6 +915,11 @@ namespace nvhttp { host_audio = util::from_view(get_arg(args, "localAudioPlayMode")); auto launch_session = make_launch_session(host_audio, args); + auto resolved_uuid = resolve_client_uuid(request); + if (!resolved_uuid.empty()) { + launch_session->unique_id = std::move(resolved_uuid); + } + if (rtsp_stream::session_count() == 0) { // The display should be restored in case something fails as there are no other sessions. revert_display_configuration = true; @@ -976,6 +1028,11 @@ namespace nvhttp { } const auto launch_session = make_launch_session(host_audio, args); + auto resolved_uuid = resolve_client_uuid(request); + if (!resolved_uuid.empty()) { + launch_session->unique_id = std::move(resolved_uuid); + } + if (no_active_sessions) { // We want to prepare display only if there are no active sessions at // the moment. This should be done before probing encoders as it could diff --git a/src/stream.cpp b/src/stream.cpp index 4c6c7e9b..e9a3bf9d 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -1972,9 +1972,9 @@ namespace stream { {"keyboard_active", kb_ago <= KB_ACTIVE_WINDOW_MS}, {"mouse_active", mouse_ago <= MOUSE_ACTIVE_WINDOW_MS}, {"gamepad_active", gp_ago <= GAMEPAD_ACTIVE_WINDOW_MS}, - {"last_keyboard_ms_ago", last_kb > 0 ? kb_ago : -1}, - {"last_mouse_ms_ago", last_mouse > 0 ? mouse_ago : -1}, - {"last_gamepad_ms_ago", last_gp > 0 ? gp_ago : -1}, + {"last_keyboard_ms_ago", last_kb > 0 ? (int64_t) kb_ago : (int64_t) -1}, + {"last_mouse_ms_ago", last_mouse > 0 ? (int64_t) mouse_ago : (int64_t) -1}, + {"last_gamepad_ms_ago", last_gp > 0 ? (int64_t) gp_ago : (int64_t) -1}, }; result.push_back(std::move(entry)); diff --git a/third-party/Simple-Web-Server b/third-party/Simple-Web-Server index 546895a9..99c1f621 160000 --- a/third-party/Simple-Web-Server +++ b/third-party/Simple-Web-Server @@ -1 +1 @@ -Subproject commit 546895a93a29062bb178367b46c7afb72da9881e +Subproject commit 99c1f621ebd8d119c5d2dc3a88ecf255058acec0 From a271a636027c1ff94ccd557758394f45174b5d75 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 11 Feb 2026 14:46:56 -0700 Subject: [PATCH 05/20] Fix crash: check joinable() before thread join in end_broadcast --- src/stream.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/stream.cpp b/src/stream.cpp index e9a3bf9d..da3bb411 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -1823,13 +1823,13 @@ namespace stream { audio_packets.reset(); BOOST_LOG(debug) << "Waiting for main listening thread to end..."sv; - ctx.recv_thread.join(); + if (ctx.recv_thread.joinable()) ctx.recv_thread.join(); BOOST_LOG(debug) << "Waiting for main video thread to end..."sv; - ctx.video_thread.join(); + if (ctx.video_thread.joinable()) ctx.video_thread.join(); BOOST_LOG(debug) << "Waiting for main audio thread to end..."sv; - ctx.audio_thread.join(); + if (ctx.audio_thread.joinable()) ctx.audio_thread.join(); BOOST_LOG(debug) << "Waiting for main control thread to end..."sv; - ctx.control_thread.join(); + if (ctx.control_thread.joinable()) ctx.control_thread.join(); BOOST_LOG(debug) << "All broadcasting threads ended"sv; broadcast_shutdown_event->reset(); From c4315144b3709dd517f6ee4057a03f29571d3dc1 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 11 Feb 2026 15:02:53 -0700 Subject: [PATCH 06/20] Avoid broadcast lifecycle races in active sessions API --- src/stream.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/stream.cpp b/src/stream.cpp index da3bb411..3be157a2 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -1932,9 +1932,17 @@ namespace stream { audio::capture(session->mail, session->config.audio, session); } + namespace session { + extern std::atomic_uint running_sessions; + } + nlohmann::json get_active_sessions_info() { auto result = nlohmann::json::array(); + if (session::running_sessions.load(std::memory_order_relaxed) == 0) { + return result; + } + auto ref = broadcast.ref(); if (!ref) { return result; From 916eb1a2135b22651774df1c43b1f03d6e790e05 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 11 Feb 2026 15:25:48 -0700 Subject: [PATCH 07/20] Support Wayland monitor selection by connector name --- src/platform/linux/wlgrab.cpp | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/platform/linux/wlgrab.cpp b/src/platform/linux/wlgrab.cpp index 9ef3e09f..c989d849 100644 --- a/src/platform/linux/wlgrab.cpp +++ b/src/platform/linux/wlgrab.cpp @@ -3,6 +3,7 @@ * @brief Definitions for wlgrab capture. */ // standard includes +#include #include // local includes @@ -57,6 +58,30 @@ namespace wl { if (streamedMonitor >= 0 && streamedMonitor < interface.monitors.size()) { monitor = interface.monitors[streamedMonitor].get(); + } else { + auto by_name = std::find_if( + std::begin(interface.monitors), + std::end(interface.monitors), + [&display_name](const auto &candidate) { + return candidate->name == display_name; + }); + + if (by_name != std::end(interface.monitors)) { + monitor = by_name->get(); + } else { + auto by_description = std::find_if( + std::begin(interface.monitors), + std::end(interface.monitors), + [&display_name](const auto &candidate) { + return candidate->description == display_name; + }); + + if (by_description != std::end(interface.monitors)) { + monitor = by_description->get(); + } else { + BOOST_LOG(warning) << "No Wayland monitor matched output_name='"sv << display_name << "'; falling back to monitor 0"sv; + } + } } } @@ -431,8 +456,12 @@ namespace platf { BOOST_LOG(info) << "Monitor " << x << " is "sv << monitor->name << ": "sv << monitor->description; - display_names.emplace_back(std::to_string(x)); - } + if (!monitor->name.empty()) { + display_names.emplace_back(monitor->name); + } else { + display_names.emplace_back(std::to_string(x)); + } + } BOOST_LOG(info) << "--------- End of Wayland monitor list ---------"sv; From 3f19554cfabe55b88b638fc1d16fdb90afab1407 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 11 Feb 2026 15:33:10 -0700 Subject: [PATCH 08/20] Improve live input indicator visibility --- src/stream.h | 6 +-- .../common/assets/web/troubleshooting.html | 40 +++++++++++++++++-- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/stream.h b/src/stream.h index 5126acaf..3335ea78 100644 --- a/src/stream.h +++ b/src/stream.h @@ -23,9 +23,9 @@ namespace stream { constexpr auto CONTROL_PORT = 10; constexpr auto AUDIO_STREAM_PORT = 11; - constexpr uint64_t KB_ACTIVE_WINDOW_MS = 500; - constexpr uint64_t MOUSE_ACTIVE_WINDOW_MS = 500; - constexpr uint64_t GAMEPAD_ACTIVE_WINDOW_MS = 500; + constexpr uint64_t KB_ACTIVE_WINDOW_MS = 1500; + constexpr uint64_t MOUSE_ACTIVE_WINDOW_MS = 1500; + constexpr uint64_t GAMEPAD_ACTIVE_WINDOW_MS = 1500; struct session_t; diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index 96efa5d1..e75e7de6 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -151,7 +151,7 @@

Live Input Status

-

Per-session input activity and policy. Indicators update every second.

+

Per-session input activity and policy. Indicators update twice per second.

No active streaming sessions. @@ -170,6 +170,9 @@ {{ s.policy.allow_keyboard ? 'ON' : 'OFF' }} + + {{ activityStateLabel(s.activity.keyboard_active, s.activity.last_keyboard_ms_ago) }} + @@ -177,6 +180,9 @@ {{ s.policy.allow_mouse ? 'ON' : 'OFF' }} + + {{ activityStateLabel(s.activity.mouse_active, s.activity.last_mouse_ms_ago) }} + @@ -184,6 +190,9 @@ {{ s.policy.allow_gamepad ? 'ON' : 'OFF' }} + + {{ activityStateLabel(s.activity.gamepad_active, s.activity.last_gamepad_ms_ago) }} +
@@ -436,7 +445,7 @@ }, 5000); this.sessionInterval = setInterval(() => { this.refreshActiveSessions(); - }, 1000); + }, 500); this.refreshLogs(); this.refreshClients(); this.refreshActiveSessions(); @@ -446,6 +455,21 @@ clearInterval(this.sessionInterval); }, methods: { + activityStateLabel(active, msAgo) { + if (active) { + return 'LIVE'; + } + + if (msAgo < 0) { + return 'never'; + } + + if (msAgo < 1000) { + return `${msAgo}ms`; + } + + return `${(msAgo / 1000).toFixed(1)}s`; + }, refreshActiveSessions() { fetch("./api/sessions/active") .then((r) => r.json()) @@ -677,18 +701,26 @@ From d5218297a4368e399916572837941a066e400be0 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 11 Feb 2026 15:43:30 -0700 Subject: [PATCH 09/20] Use dot-only live input indicators in troubleshooting page --- .../common/assets/web/troubleshooting.html | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index e75e7de6..5def6e7f 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -170,9 +170,6 @@ {{ s.policy.allow_keyboard ? 'ON' : 'OFF' }} - - {{ activityStateLabel(s.activity.keyboard_active, s.activity.last_keyboard_ms_ago) }} - @@ -180,9 +177,6 @@ {{ s.policy.allow_mouse ? 'ON' : 'OFF' }} - - {{ activityStateLabel(s.activity.mouse_active, s.activity.last_mouse_ms_ago) }} - @@ -190,9 +184,6 @@ {{ s.policy.allow_gamepad ? 'ON' : 'OFF' }} - - {{ activityStateLabel(s.activity.gamepad_active, s.activity.last_gamepad_ms_ago) }} -
@@ -455,21 +446,6 @@ clearInterval(this.sessionInterval); }, methods: { - activityStateLabel(active, msAgo) { - if (active) { - return 'LIVE'; - } - - if (msAgo < 0) { - return 'never'; - } - - if (msAgo < 1000) { - return `${msAgo}ms`; - } - - return `${(msAgo / 1000).toFixed(1)}s`; - }, refreshActiveSessions() { fetch("./api/sessions/active") .then((r) => r.json()) @@ -715,12 +691,6 @@ .dot-gray { background-color: #6b7280; } - .activity-state { - min-width: 3.25rem; - font-size: 0.72em; - text-transform: uppercase; - letter-spacing: 0.02em; - } From 54afa9bb67df6c024f43ff7a77757d7bc9d90245 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 11 Feb 2026 16:02:49 -0700 Subject: [PATCH 10/20] Push live session activity over WebSocket with polling fallback --- src/confighttp.cpp | 275 +++++++++++++++++- .../common/assets/web/troubleshooting.html | 58 ++++ 2 files changed, 320 insertions(+), 13 deletions(-) diff --git a/src/confighttp.cpp b/src/confighttp.cpp index f718b190..1372a594 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -7,10 +7,18 @@ #define BOOST_BIND_GLOBAL_PLACEHOLDERS // standard includes +#include +#include +#include +#include #include #include #include +#include #include +#include +#include +#include // lib includes #include @@ -19,6 +27,8 @@ #include #include #include +#include +#include #ifdef _WIN32 #include "platform/windows/misc.h" @@ -55,6 +65,119 @@ namespace confighttp { using resp_https_t = std::shared_ptr::Response>; using req_https_t = std::shared_ptr::Request>; + namespace { + struct ws_client_t { + std::unique_ptr socket; + std::mutex write_mutex; + std::atomic_bool alive {true}; + }; + + constexpr std::chrono::seconds WS_TOKEN_TTL {30}; + constexpr std::chrono::milliseconds WS_PUSH_INTERVAL {250}; + constexpr std::string_view WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + std::mutex ws_clients_mutex; + std::vector> ws_clients; + + std::mutex ws_tokens_mutex; + std::unordered_map ws_tokens; + + void cleanup_ws_tokens(const std::chrono::steady_clock::time_point &now) { + for (auto it = ws_tokens.begin(); it != ws_tokens.end();) { + if (it->second <= now) { + it = ws_tokens.erase(it); + } else { + ++it; + } + } + } + + std::string create_ws_token() { + auto now = std::chrono::steady_clock::now(); + auto token = uuid_util::uuid_t::generate().string(); + + std::lock_guard lg {ws_tokens_mutex}; + cleanup_ws_tokens(now); + ws_tokens[token] = now + WS_TOKEN_TTL; + + return token; + } + + bool consume_ws_token(std::string_view token) { + auto now = std::chrono::steady_clock::now(); + + std::lock_guard lg {ws_tokens_mutex}; + cleanup_ws_tokens(now); + + auto it = ws_tokens.find(std::string(token)); + if (it == ws_tokens.end()) { + return false; + } + + ws_tokens.erase(it); + return true; + } + + std::string websocket_accept_key(std::string_view key) { + std::string input; + input.reserve(key.size() + WS_GUID.size()); + input.append(key); + input.append(WS_GUID); + + std::array digest {}; + SHA1(reinterpret_cast(input.data()), input.size(), digest.data()); + + std::array encoded {}; + auto len = EVP_EncodeBlock(encoded.data(), digest.data(), digest.size()); + + return std::string(reinterpret_cast(encoded.data()), len); + } + + std::string websocket_text_frame(const std::string &payload) { + std::string frame; + frame.reserve(payload.size() + 10); + frame.push_back(static_cast(0x81)); + + auto len = payload.size(); + if (len <= 125) { + frame.push_back(static_cast(len)); + } else if (len <= 0xFFFF) { + frame.push_back(static_cast(126)); + frame.push_back(static_cast((len >> 8) & 0xFF)); + frame.push_back(static_cast(len & 0xFF)); + } else { + frame.push_back(static_cast(127)); + for (int i = 7; i >= 0; --i) { + frame.push_back(static_cast((len >> (i * 8)) & 0xFF)); + } + } + + frame.append(payload); + return frame; + } + + void ws_write_http_response(std::unique_ptr &socket, SimpleWeb::StatusCode code, std::string_view body) { + if (!socket) { + return; + } + + std::string response; + response.reserve(256 + body.size()); + response.append("HTTP/1.1 "); + response.append(SimpleWeb::status_code(code)); + response.append("\r\nContent-Type: application/json\r\n"); + response.append("Connection: close\r\n"); + response.append("Content-Length: "); + response.append(std::to_string(body.size())); + response.append("\r\n\r\n"); + response.append(body); + + boost::system::error_code ec; + boost::asio::write(*socket, boost::asio::buffer(response), ec); + socket->lowest_layer().close(ec); + } + } + enum class op_e { ADD, ///< Add client REMOVE ///< Remove client @@ -137,6 +260,24 @@ namespace confighttp { response->write(SimpleWeb::StatusCode::redirection_temporary_redirect, headers); } + bool authenticate_header(std::string_view raw_auth) { + if (raw_auth.size() < "Basic "sv.size() || raw_auth.substr(0, "Basic "sv.size()) != "Basic "sv) { + return false; + } + + auto auth_data = SimpleWeb::Crypto::Base64::decode(std::string(raw_auth.substr("Basic "sv.length()))); + auto index = (int) auth_data.find(':'); + if (index >= auth_data.size() - 1) { + return false; + } + + auto username = auth_data.substr(0, index); + auto password = auth_data.substr(index + 1); + auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); + + return boost::iequals(username, config::sunshine.username) && hash == config::sunshine.password; + } + /** * @brief Authenticate the user. * @param response The HTTP response object. @@ -168,19 +309,7 @@ namespace confighttp { return false; } - auto &rawAuth = auth->second; - auto authData = SimpleWeb::Crypto::Base64::decode(rawAuth.substr("Basic "sv.length())); - - auto index = (int) authData.find(':'); - if (index >= authData.size() - 1) { - return false; - } - - auto username = authData.substr(0, index); - auto password = authData.substr(index + 1); - auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); - - if (!boost::iequals(username, config::sunshine.username) || hash != config::sunshine.password) { + if (!authenticate_header(auth->second)) { return false; } @@ -819,6 +948,19 @@ namespace confighttp { send_response(response, output_tree); } + void getActiveSessionsWsToken(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + nlohmann::json output_tree; + output_tree["token"] = create_ws_token(); + output_tree["status"] = true; + send_response(response, output_tree); + } + /** * @brief Unpair a client. * @param response The HTTP response object. @@ -1469,12 +1611,72 @@ namespace confighttp { server.resource["^/api/clients/list$"]["GET"] = getClients; server.resource["^/api/clients/unpair$"]["POST"] = unpair; server.resource["^/api/sessions/active$"]["GET"] = getActiveSessions; + server.resource["^/api/sessions/ws-token$"]["GET"] = getActiveSessionsWsToken; server.resource["^/api/apps/close$"]["POST"] = closeApp; server.resource["^/api/covers/upload$"]["POST"] = uploadCover; server.resource["^/api/covers/([0-9]+)$"]["GET"] = getCover; server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getSunshineLogoImage; server.resource["^/assets\\/.+$"]["GET"] = getNodeModules; + + server.on_upgrade = [](std::unique_ptr &socket, req_https_t request) { + if (!socket || request->path != "/api/sessions/active/ws") { + ws_write_http_response(socket, SimpleWeb::StatusCode::client_error_not_found, R"({"status":false})"); + return; + } + + auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); + if (net::from_address(address) > http::origin_web_ui_allowed) { + ws_write_http_response(socket, SimpleWeb::StatusCode::client_error_forbidden, R"({"status":false})"); + return; + } + + if (config::sunshine.username.empty()) { + ws_write_http_response(socket, SimpleWeb::StatusCode::client_error_unauthorized, R"({"status":false})"); + return; + } + + auto auth = request->header.find("Authorization"); + if (auth != request->header.end() && !authenticate_header(auth->second)) { + ws_write_http_response(socket, SimpleWeb::StatusCode::client_error_unauthorized, R"({"status":false})"); + return; + } + + auto query = request->parse_query_string(); + auto token_it = query.find("token"); + if (token_it == query.end() || !consume_ws_token(token_it->second)) { + ws_write_http_response(socket, SimpleWeb::StatusCode::client_error_forbidden, R"({"status":false})"); + return; + } + + auto key_it = request->header.find("Sec-WebSocket-Key"); + if (key_it == request->header.end()) { + ws_write_http_response(socket, SimpleWeb::StatusCode::client_error_bad_request, R"({"status":false})"); + return; + } + + std::string response; + response.reserve(256); + response.append("HTTP/1.1 101 Switching Protocols\r\n"); + response.append("Upgrade: websocket\r\n"); + response.append("Connection: Upgrade\r\n"); + response.append("Sec-WebSocket-Accept: "); + response.append(websocket_accept_key(key_it->second)); + response.append("\r\n\r\n"); + + boost::system::error_code ec; + boost::asio::write(*socket, boost::asio::buffer(response), ec); + if (ec) { + socket->lowest_layer().close(ec); + return; + } + + auto client = std::make_shared(); + client->socket = std::move(socket); + + std::lock_guard lg {ws_clients_mutex}; + ws_clients.emplace_back(std::move(client)); + }; server.config.reuse_address = true; server.config.address = net::get_bind_address(address_family); server.config.port = port_https; @@ -1498,11 +1700,58 @@ namespace confighttp { }; std::thread tcp {accept_and_run, &server}; + std::thread ws_broadcast {[&] { + platf::set_thread_name("confighttp::ws"); + + while (!shutdown_event->peek()) { + std::vector> clients; + { + std::lock_guard lg {ws_clients_mutex}; + clients = ws_clients; + } + + if (!clients.empty()) { + nlohmann::json output_tree; + output_tree["sessions"] = stream::get_active_sessions_info(); + output_tree["status"] = true; + auto frame = websocket_text_frame(output_tree.dump()); + + for (auto &client : clients) { + if (!client->alive.load(std::memory_order_relaxed)) { + continue; + } + + std::lock_guard write_lg {client->write_mutex}; + if (!client->socket) { + client->alive.store(false, std::memory_order_relaxed); + continue; + } + + boost::system::error_code ec; + boost::asio::write(*client->socket, boost::asio::buffer(frame), ec); + if (ec) { + client->alive.store(false, std::memory_order_relaxed); + client->socket->lowest_layer().close(ec); + } + } + + std::lock_guard lg {ws_clients_mutex}; + ws_clients.erase(std::remove_if(std::begin(ws_clients), std::end(ws_clients), [](const auto &client) { + return !client->alive.load(std::memory_order_relaxed); + }), std::end(ws_clients)); + } + + std::this_thread::sleep_for(WS_PUSH_INTERVAL); + } + }}; + // Wait for any event shutdown_event->view(); server.stop(); + ws_broadcast.join(); + tcp.join(); } } // namespace confighttp diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index 5def6e7f..e0cc53c0 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -282,6 +282,8 @@ logFilter: null, logInterval: null, sessionInterval: null, + sessionSocket: null, + sessionReconnectTimer: null, restartPressed: false, showApplyMessage: false, platform: "", @@ -440,13 +442,69 @@ this.refreshLogs(); this.refreshClients(); this.refreshActiveSessions(); + this.connectActiveSessionsSocket(); }, beforeDestroy() { clearInterval(this.logInterval); clearInterval(this.sessionInterval); + clearTimeout(this.sessionReconnectTimer); + if (this.sessionSocket) { + this.sessionSocket.onclose = null; + this.sessionSocket.close(); + this.sessionSocket = null; + } }, methods: { + connectActiveSessionsSocket() { + fetch("./api/sessions/ws-token") + .then((r) => r.json()) + .then((r) => { + if (!r || r.status !== true || !r.token) { + throw new Error("No websocket token"); + } + + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const wsUrl = `${protocol}://${window.location.host}/api/sessions/active/ws?token=${encodeURIComponent(r.token)}`; + + this.sessionSocket = new WebSocket(wsUrl); + + this.sessionSocket.onmessage = (event) => { + try { + const payload = JSON.parse(event.data); + if (payload && payload.status === true && payload.sessions) { + this.activeSessions = payload.sessions; + } + } catch (_e) { + return; + } + }; + + this.sessionSocket.onclose = () => { + this.sessionSocket = null; + clearTimeout(this.sessionReconnectTimer); + this.sessionReconnectTimer = setTimeout(() => { + this.connectActiveSessionsSocket(); + }, 1000); + }; + + this.sessionSocket.onerror = () => { + if (this.sessionSocket) { + this.sessionSocket.close(); + } + }; + }) + .catch(() => { + clearTimeout(this.sessionReconnectTimer); + this.sessionReconnectTimer = setTimeout(() => { + this.connectActiveSessionsSocket(); + }, 1000); + }); + }, refreshActiveSessions() { + if (this.sessionSocket && this.sessionSocket.readyState === WebSocket.OPEN) { + return; + } + fetch("./api/sessions/active") .then((r) => r.json()) .then((r) => { From 718c45b76a4c92a412e7ba458febe545d2f99e2b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 11 Feb 2026 16:22:40 -0700 Subject: [PATCH 11/20] Improve visibility of live input activity dots --- src_assets/common/assets/web/troubleshooting.html | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index e0cc53c0..a5530d04 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -735,19 +735,22 @@ From 1981060f09c7f84b19006b7ee75886bd520fc58a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 11 Feb 2026 16:30:43 -0700 Subject: [PATCH 12/20] Load live input indicator styles from shared stylesheet --- .../assets/web/public/assets/css/sunshine.css | 22 +++++++++++++++++++ .../common/assets/web/troubleshooting.html | 22 ------------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src_assets/common/assets/web/public/assets/css/sunshine.css b/src_assets/common/assets/web/public/assets/css/sunshine.css index 970f2e2e..2f705a4a 100644 --- a/src_assets/common/assets/web/public/assets/css/sunshine.css +++ b/src_assets/common/assets/web/public/assets/css/sunshine.css @@ -1202,6 +1202,28 @@ p { margin: 0; } +.activity-dot { + display: inline-block; + width: 14px; + height: 14px; + border-radius: 50%; + transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s; + flex-shrink: 0; + vertical-align: middle; + margin-right: 3px; +} + +.dot-green { + background-color: #22c55e; + box-shadow: 0 0 6px #22c55e; + border: 1px solid #16a34a; +} + +.dot-gray { + background-color: #6b7280; + border: 1px solid #d1d5db; +} + /* Log level highlighting */ .log-line-info { color: var(--color-text-base); diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index a5530d04..b27765cd 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -732,26 +732,4 @@ initApp(app); - - From e2d3f23575d3e63480d9216984fca2506d602088 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 11 Feb 2026 21:31:23 -0700 Subject: [PATCH 13/20] Clear stale input state on session reset --- src/input.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/input.cpp b/src/input.cpp index f57a28cb..8a77ab58 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -1668,8 +1668,8 @@ namespace input { for (int x = 0; x < mouse_press.size(); ++x) { if (mouse_press[x]) { platf::button_mouse(platf_input, x, true); - mouse_press[x] = false; } + mouse_press[x] = false; } for (auto &kp : key_press) { @@ -1680,7 +1680,10 @@ namespace input { platf::keyboard_update(platf_input, vk_from_kpid(kp.first) & 0x00FF, true, flags_from_kpid(kp.first)); key_press[kp.first] = false; } - }); + + key_press.clear(); + key_press_repeat_id = nullptr; + }).wait(); } class deinit_t: public platf::deinit_t { From 5d0a6106e61c274597c115c7a3d0a3370c060336 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 12 Feb 2026 00:13:42 -0700 Subject: [PATCH 14/20] Apply per-session input policy updates via control stream --- src/input.cpp | 63 ++++++++++++++++++++++++++++++------------ src/input.h | 6 ++++ src/stream.cpp | 75 ++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 115 insertions(+), 29 deletions(-) diff --git a/src/input.cpp b/src/input.cpp index 8a77ab58..908fec79 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -184,7 +184,10 @@ namespace input { mouse_left_button_timeout {}, touch_port {{0, 0, 0, 0}, 0, 0, 1.0f, 1.0f, 0, 0}, accumulated_vscroll_delta {}, - accumulated_hscroll_delta {} { + accumulated_hscroll_delta {}, + allow_keyboard {true}, + allow_mouse {true}, + allow_gamepad {true} { } // Keep track of alt+ctrl+shift key combo @@ -209,8 +212,24 @@ namespace input { std::atomic *activity_keyboard_ms = nullptr; std::atomic *activity_mouse_ms = nullptr; std::atomic *activity_gamepad_ms = nullptr; + + std::atomic allow_keyboard; + std::atomic allow_mouse; + std::atomic allow_gamepad; }; + static inline bool keyboard_allowed(const std::shared_ptr &input) { + return config::input.keyboard && input->allow_keyboard.load(std::memory_order_relaxed); + } + + static inline bool mouse_allowed(const std::shared_ptr &input) { + return config::input.mouse && input->allow_mouse.load(std::memory_order_relaxed); + } + + static inline bool gamepad_allowed(const std::shared_ptr &input) { + return config::input.controller && input->allow_gamepad.load(std::memory_order_relaxed); + } + /** * @brief Apply shortcut based on VKEY * @param keyCode The VKEY code @@ -458,7 +477,7 @@ namespace input { } void passthrough(std::shared_ptr &input, PNV_REL_MOUSE_MOVE_PACKET packet) { - if (!config::input.mouse) { + if (!mouse_allowed(input)) { return; } @@ -557,7 +576,7 @@ namespace input { } void passthrough(std::shared_ptr &input, PNV_ABS_MOUSE_MOVE_PACKET packet) { - if (!config::input.mouse) { + if (!mouse_allowed(input)) { return; } @@ -609,7 +628,7 @@ namespace input { } void passthrough(std::shared_ptr &input, PNV_MOUSE_BUTTON_PACKET packet) { - if (!config::input.mouse) { + if (!mouse_allowed(input)) { return; } @@ -775,7 +794,7 @@ namespace input { } void passthrough(std::shared_ptr &input, PNV_KEYBOARD_PACKET packet) { - if (!config::input.keyboard) { + if (!keyboard_allowed(input)) { return; } @@ -837,7 +856,7 @@ namespace input { * @param packet The scroll packet. */ void passthrough(std::shared_ptr &input, PNV_SCROLL_PACKET packet) { - if (!config::input.mouse) { + if (!mouse_allowed(input)) { return; } @@ -862,7 +881,7 @@ namespace input { * @param packet The scroll packet. */ void passthrough(std::shared_ptr &input, PSS_HSCROLL_PACKET packet) { - if (!config::input.mouse) { + if (!mouse_allowed(input)) { return; } @@ -881,8 +900,8 @@ namespace input { } } - void passthrough(PNV_UNICODE_PACKET packet) { - if (!config::input.keyboard) { + void passthrough(std::shared_ptr &input, PNV_UNICODE_PACKET packet) { + if (!keyboard_allowed(input)) { return; } @@ -896,7 +915,7 @@ namespace input { * @param packet The controller arrival packet. */ void passthrough(std::shared_ptr &input, PSS_CONTROLLER_ARRIVAL_PACKET packet) { - if (!config::input.controller) { + if (!gamepad_allowed(input)) { return; } @@ -938,7 +957,7 @@ namespace input { * @param packet The touch packet. */ void passthrough(std::shared_ptr &input, PSS_TOUCH_PACKET packet) { - if (!config::input.mouse) { + if (!mouse_allowed(input)) { return; } @@ -996,7 +1015,7 @@ namespace input { * @param packet The pen packet. */ void passthrough(std::shared_ptr &input, PSS_PEN_PACKET packet) { - if (!config::input.mouse) { + if (!mouse_allowed(input)) { return; } @@ -1056,7 +1075,7 @@ namespace input { * @param packet The controller touch packet. */ void passthrough(std::shared_ptr &input, PSS_CONTROLLER_TOUCH_PACKET packet) { - if (!config::input.controller) { + if (!gamepad_allowed(input)) { return; } @@ -1091,7 +1110,7 @@ namespace input { * @param packet The controller motion packet. */ void passthrough(std::shared_ptr &input, PSS_CONTROLLER_MOTION_PACKET packet) { - if (!config::input.controller) { + if (!gamepad_allowed(input)) { return; } @@ -1125,7 +1144,7 @@ namespace input { * @param packet The controller battery packet. */ void passthrough(std::shared_ptr &input, PSS_CONTROLLER_BATTERY_PACKET packet) { - if (!config::input.controller) { + if (!gamepad_allowed(input)) { return; } @@ -1152,7 +1171,7 @@ namespace input { } void passthrough(std::shared_ptr &input, PNV_MULTI_CONTROLLER_PACKET packet) { - if (!config::input.controller) { + if (!gamepad_allowed(input)) { return; } @@ -1620,7 +1639,7 @@ namespace input { passthrough(input, (PNV_KEYBOARD_PACKET) payload); break; case UTF8_TEXT_EVENT_MAGIC: - passthrough((PNV_UNICODE_PACKET) payload); + passthrough(input, (PNV_UNICODE_PACKET) payload); break; case MULTI_CONTROLLER_MAGIC_GEN5: passthrough(input, (PNV_MULTI_CONTROLLER_PACKET) payload); @@ -1735,4 +1754,14 @@ namespace input { input->activity_mouse_ms = mouse_ms; input->activity_gamepad_ms = gamepad_ms; } + + void set_policy( + std::shared_ptr &input, + bool allow_keyboard, + bool allow_mouse, + bool allow_gamepad) { + input->allow_keyboard.store(allow_keyboard, std::memory_order_relaxed); + input->allow_mouse.store(allow_mouse, std::memory_order_relaxed); + input->allow_gamepad.store(allow_gamepad, std::memory_order_relaxed); + } } // namespace input diff --git a/src/input.h b/src/input.h index 82a52e6e..9426db71 100644 --- a/src/input.h +++ b/src/input.h @@ -30,6 +30,12 @@ namespace input { std::atomic *mouse_ms, std::atomic *gamepad_ms); + void set_policy( + std::shared_ptr &input, + bool allow_keyboard, + bool allow_mouse, + bool allow_gamepad); + struct touch_port_t: public platf::touch_port_t { int env_width, env_height; diff --git a/src/stream.cpp b/src/stream.cpp index 3be157a2..3379878c 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -51,6 +51,7 @@ extern "C" { #define IDX_SET_MOTION_EVENT 13 #define IDX_SET_RGB_LED 14 #define IDX_SET_ADAPTIVE_TRIGGERS 15 +#define IDX_SET_INPUT_POLICY 16 static const short packetTypes[] = { 0x0305, // Start A @@ -69,6 +70,7 @@ static const short packetTypes[] = { 0x5501, // Set motion event (Sunshine protocol extension) 0x5502, // Set RGB LED (Sunshine protocol extension) 0x5503, // Set Adaptive triggers (Sunshine protocol extension) + 0x5504, }; namespace asio = boost::asio; @@ -205,6 +207,13 @@ namespace stream { std::uint8_t right[DS_EFFECT_PAYLOAD_SIZE]; }; + struct control_set_input_policy_t { + std::uint8_t allow_keyboard; + std::uint8_t allow_mouse; + std::uint8_t allow_gamepad; + std::uint8_t reason; + }; + struct control_hdr_mode_t { control_header_v2 header; @@ -346,10 +355,10 @@ namespace stream { * @brief Per-session input policy (which remote input types are allowed). */ struct session_input_policy_t { - bool allow_gamepad = true; - bool allow_keyboard = false; - bool allow_mouse = false; - bool is_owner_session = false; + std::atomic allow_gamepad {true}; + std::atomic allow_keyboard {false}; + std::atomic allow_mouse {false}; + std::atomic is_owner_session {false}; }; /** @@ -448,6 +457,24 @@ namespace stream { std::atomic state; }; + static void apply_session_input_policy(session_t *session, bool allow_keyboard, bool allow_mouse, bool allow_gamepad, uint8_t reason) { + auto effective_keyboard = config::input.keyboard && allow_keyboard; + auto effective_mouse = config::input.mouse && allow_mouse; + auto effective_gamepad = config::input.controller && allow_gamepad; + + session->input_policy.allow_keyboard.store(effective_keyboard, std::memory_order_relaxed); + session->input_policy.allow_mouse.store(effective_mouse, std::memory_order_relaxed); + session->input_policy.allow_gamepad.store(effective_gamepad, std::memory_order_relaxed); + + if (session->input) { + input::set_policy(session->input, effective_keyboard, effective_mouse, effective_gamepad); + } + + BOOST_LOG(debug) + << "Session input policy updated [reason="sv << (int) reason << "] kb="sv << effective_keyboard + << " mouse="sv << effective_mouse << " gamepad="sv << effective_gamepad; + } + /** * First part of cipher must be struct of type control_encrypted_t * @@ -1095,6 +1122,23 @@ namespace stream { } }); + server->map(packetTypes[IDX_SET_INPUT_POLICY], [](session_t *session, const std::string_view &payload) { + BOOST_LOG(debug) << "type [IDX_SET_INPUT_POLICY]"sv; + + if (payload.size() < sizeof(control_set_input_policy_t)) { + BOOST_LOG(warning) << "Dropping runt input policy payload"sv; + return; + } + + auto *policy = (const control_set_input_policy_t *) payload.data(); + apply_session_input_policy( + session, + policy->allow_keyboard != 0, + policy->allow_mouse != 0, + policy->allow_gamepad != 0, + policy->reason); + }); + // This thread handles latency-sensitive control messages platf::set_thread_name("stream::controlBroadcast"); platf::adjust_thread_priority(platf::thread_priority_e::critical); @@ -1964,12 +2008,12 @@ namespace stream { entry["session_id"] = session->launch_session_id; entry["client_uuid"] = session->client_unique_id; entry["client_name"] = session->client_name; - entry["is_owner_session"] = session->input_policy.is_owner_session; + entry["is_owner_session"] = session->input_policy.is_owner_session.load(std::memory_order_relaxed); entry["policy"] = { - {"allow_keyboard", session->input_policy.allow_keyboard}, - {"allow_mouse", session->input_policy.allow_mouse}, - {"allow_gamepad", session->input_policy.allow_gamepad}, + {"allow_keyboard", session->input_policy.allow_keyboard.load(std::memory_order_relaxed)}, + {"allow_mouse", session->input_policy.allow_mouse.load(std::memory_order_relaxed)}, + {"allow_gamepad", session->input_policy.allow_gamepad.load(std::memory_order_relaxed)}, }; auto kb_ago = last_kb > 0 ? now - last_kb : UINT64_MAX; @@ -2065,6 +2109,13 @@ namespace stream { &session.input_activity.last_mouse_ms, &session.input_activity.last_gamepad_ms); + apply_session_input_policy( + &session, + session.input_policy.allow_keyboard.load(std::memory_order_relaxed), + session.input_policy.allow_mouse.load(std::memory_order_relaxed), + session.input_policy.allow_gamepad.load(std::memory_order_relaxed), + 0); + session.broadcast_ref = broadcast.ref(); if (!session.broadcast_ref) { return -1; @@ -2117,10 +2168,10 @@ namespace stream { auto &owner_uuids = config::input.owner_client_uuids; bool is_owner = std::find(owner_uuids.begin(), owner_uuids.end(), launch_session.unique_id) != owner_uuids.end(); - session->input_policy.is_owner_session = is_owner; - session->input_policy.allow_gamepad = true; - session->input_policy.allow_keyboard = is_owner; - session->input_policy.allow_mouse = is_owner; + session->input_policy.is_owner_session.store(is_owner, std::memory_order_relaxed); + session->input_policy.allow_gamepad.store(true, std::memory_order_relaxed); + session->input_policy.allow_keyboard.store(is_owner, std::memory_order_relaxed); + session->input_policy.allow_mouse.store(is_owner, std::memory_order_relaxed); if (is_owner) { BOOST_LOG(info) << "Owner session detected for client: "sv << launch_session.unique_id; From 6fdeb1b868a6c52aef0fe52d92661559b11be4f9 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 12 Feb 2026 00:38:13 -0700 Subject: [PATCH 15/20] Send input policy to clients on start and ack --- src/stream.cpp | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/stream.cpp b/src/stream.cpp index 3379878c..4685b05f 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -53,6 +53,11 @@ extern "C" { #define IDX_SET_ADAPTIVE_TRIGGERS 15 #define IDX_SET_INPUT_POLICY 16 +#define INPUT_POLICY_REASON_STREAM_START 0 +#define INPUT_POLICY_REASON_USER_TOGGLE 1 +#define INPUT_POLICY_REASON_HOST_ACK 2 +#define INPUT_POLICY_REASON_HOST_OVERRIDE 3 + static const short packetTypes[] = { 0x0305, // Start A 0x0307, // Start B @@ -475,6 +480,30 @@ namespace stream { << " mouse="sv << effective_mouse << " gamepad="sv << effective_gamepad; } + static int send_session_input_policy(session_t *session, uint8_t reason) { + if (!session->control.peer) { + return -1; + } + + control_set_input_policy_t plaintext; + plaintext.header.type = packetTypes[IDX_SET_INPUT_POLICY]; + plaintext.header.payloadLength = sizeof(plaintext) - sizeof(control_header_v2); + plaintext.allow_keyboard = session->input_policy.allow_keyboard.load(std::memory_order_relaxed) ? 1 : 0; + plaintext.allow_mouse = session->input_policy.allow_mouse.load(std::memory_order_relaxed) ? 1 : 0; + plaintext.allow_gamepad = session->input_policy.allow_gamepad.load(std::memory_order_relaxed) ? 1 : 0; + plaintext.reason = reason; + + std::array + encrypted_payload; + + auto payload = encode_control(session, util::view(plaintext), encrypted_payload); + if (session->broadcast_ref->control_server.send(payload, session->control.peer)) { + return -1; + } + + return 0; + } + /** * First part of cipher must be struct of type control_encrypted_t * @@ -993,6 +1022,10 @@ namespace stream { server->map(packetTypes[IDX_START_B], [&](session_t *session, const std::string_view &payload) { BOOST_LOG(debug) << "type [IDX_START_B]"sv; + + if (send_session_input_policy(session, INPUT_POLICY_REASON_STREAM_START)) { + BOOST_LOG(warning) << "Unable to send initial session input policy"sv; + } }); server->map(packetTypes[IDX_LOSS_STATS], [&](session_t *session, const std::string_view &payload) { @@ -1137,6 +1170,10 @@ namespace stream { policy->allow_mouse != 0, policy->allow_gamepad != 0, policy->reason); + + if (send_session_input_policy(session, INPUT_POLICY_REASON_HOST_ACK)) { + BOOST_LOG(warning) << "Unable to send input policy acknowledgment"sv; + } }); // This thread handles latency-sensitive control messages From 07c8a7b5fb03987e07d765c08ae6f302f25eb044 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 12 Feb 2026 00:42:02 -0700 Subject: [PATCH 16/20] Fix input policy control packet framing and declaration order --- src/stream.cpp | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/stream.cpp b/src/stream.cpp index 4685b05f..29132eba 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -462,6 +462,9 @@ namespace stream { std::atomic state; }; + template + static inline std::string_view encode_control(session_t *session, const std::string_view &plaintext, std::array &tagged_cipher); + static void apply_session_input_policy(session_t *session, bool allow_keyboard, bool allow_mouse, bool allow_gamepad, uint8_t reason) { auto effective_keyboard = config::input.keyboard && allow_keyboard; auto effective_mouse = config::input.mouse && allow_mouse; @@ -485,13 +488,17 @@ namespace stream { return -1; } - control_set_input_policy_t plaintext; + struct control_set_input_policy_msg_t { + control_header_v2 header; + control_set_input_policy_t payload; + } plaintext; + plaintext.header.type = packetTypes[IDX_SET_INPUT_POLICY]; plaintext.header.payloadLength = sizeof(plaintext) - sizeof(control_header_v2); - plaintext.allow_keyboard = session->input_policy.allow_keyboard.load(std::memory_order_relaxed) ? 1 : 0; - plaintext.allow_mouse = session->input_policy.allow_mouse.load(std::memory_order_relaxed) ? 1 : 0; - plaintext.allow_gamepad = session->input_policy.allow_gamepad.load(std::memory_order_relaxed) ? 1 : 0; - plaintext.reason = reason; + plaintext.payload.allow_keyboard = session->input_policy.allow_keyboard.load(std::memory_order_relaxed) ? 1 : 0; + plaintext.payload.allow_mouse = session->input_policy.allow_mouse.load(std::memory_order_relaxed) ? 1 : 0; + plaintext.payload.allow_gamepad = session->input_policy.allow_gamepad.load(std::memory_order_relaxed) ? 1 : 0; + plaintext.payload.reason = reason; std::array encrypted_payload; From 7e2ef2037c83d371cc32d27724c8203c7dd68527 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 12 Feb 2026 01:36:59 -0700 Subject: [PATCH 17/20] Reduce troubleshooting session status polling frequency --- src_assets/common/assets/web/troubleshooting.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index b27765cd..94f3b5f9 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -438,7 +438,7 @@ }, 5000); this.sessionInterval = setInterval(() => { this.refreshActiveSessions(); - }, 500); + }, 5000); this.refreshLogs(); this.refreshClients(); this.refreshActiveSessions(); From 69c269398484b167a01da214b34ba3a8b0dab88c Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 12 Feb 2026 08:43:39 -0700 Subject: [PATCH 18/20] Add host-side per-session input policy controls --- src/confighttp.cpp | 62 +++++++++++++ src/stream.cpp | 49 ++++++++++ src/stream.h | 10 ++ .../common/assets/web/troubleshooting.html | 91 ++++++++++++++++++- 4 files changed, 211 insertions(+), 1 deletion(-) diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 1372a594..599aba0b 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -961,6 +961,67 @@ namespace confighttp { send_response(response, output_tree); } + void setActiveSessionPolicy(resp_https_t response, req_https_t request) { + if (!check_content_type(response, request, "application/json")) { + return; + } + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + std::stringstream ss; + ss << request->content.rdbuf(); + + try { + nlohmann::json output_tree; + const nlohmann::json input_tree = nlohmann::json::parse(ss); + + if (!input_tree.contains("session_id") || + !input_tree.contains("allow_keyboard") || + !input_tree.contains("allow_mouse") || + !input_tree.contains("allow_gamepad")) { + bad_request(response, request, "Missing required session policy fields"); + return; + } + + const uint32_t session_id = input_tree.at("session_id").get(); + const bool allow_keyboard = input_tree.at("allow_keyboard").get(); + const bool allow_mouse = input_tree.at("allow_mouse").get(); + const bool allow_gamepad = input_tree.at("allow_gamepad").get(); + + bool effective_keyboard; + bool effective_mouse; + bool effective_gamepad; + const bool updated = stream::set_active_session_input_policy( + session_id, + allow_keyboard, + allow_mouse, + allow_gamepad, + &effective_keyboard, + &effective_mouse, + &effective_gamepad); + + output_tree["status"] = updated; + if (!updated) { + output_tree["error"] = "Active session not found"; + } else { + output_tree["session_id"] = session_id; + output_tree["policy"] = { + {"allow_keyboard", effective_keyboard}, + {"allow_mouse", effective_mouse}, + {"allow_gamepad", effective_gamepad}, + }; + } + + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "SetActiveSessionPolicy: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + /** * @brief Unpair a client. * @param response The HTTP response object. @@ -1612,6 +1673,7 @@ namespace confighttp { server.resource["^/api/clients/unpair$"]["POST"] = unpair; server.resource["^/api/sessions/active$"]["GET"] = getActiveSessions; server.resource["^/api/sessions/ws-token$"]["GET"] = getActiveSessionsWsToken; + server.resource["^/api/sessions/policy$"]["POST"] = setActiveSessionPolicy; server.resource["^/api/apps/close$"]["POST"] = closeApp; server.resource["^/api/covers/upload$"]["POST"] = uploadCover; server.resource["^/api/covers/([0-9]+)$"]["GET"] = getCover; diff --git a/src/stream.cpp b/src/stream.cpp index 29132eba..d8c29690 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -2079,6 +2079,55 @@ namespace stream { return result; } + bool set_active_session_input_policy( + uint32_t session_id, + bool allow_keyboard, + bool allow_mouse, + bool allow_gamepad, + bool *effective_allow_keyboard, + bool *effective_allow_mouse, + bool *effective_allow_gamepad) { + auto ref = broadcast.ref(); + if (!ref) { + return false; + } + + auto lg = ref->control_server._sessions.lock(); + for (auto *session : *ref->control_server._sessions) { + if (session->launch_session_id != session_id || session->state.load(std::memory_order_relaxed) != session::state_e::RUNNING) { + continue; + } + + apply_session_input_policy(session, allow_keyboard, allow_mouse, allow_gamepad, INPUT_POLICY_REASON_HOST_OVERRIDE); + + if (send_session_input_policy(session, INPUT_POLICY_REASON_HOST_OVERRIDE)) { + BOOST_LOG(warning) << "Unable to send host policy override to client"sv; + } + + auto final_allow_keyboard = session->input_policy.allow_keyboard.load(std::memory_order_relaxed); + auto final_allow_mouse = session->input_policy.allow_mouse.load(std::memory_order_relaxed); + auto final_allow_gamepad = session->input_policy.allow_gamepad.load(std::memory_order_relaxed); + + if (effective_allow_keyboard) { + *effective_allow_keyboard = final_allow_keyboard; + } + if (effective_allow_mouse) { + *effective_allow_mouse = final_allow_mouse; + } + if (effective_allow_gamepad) { + *effective_allow_gamepad = final_allow_gamepad; + } + + BOOST_LOG(info) + << "Host input policy override applied [session="sv << session_id << "] kb="sv << final_allow_keyboard + << " mouse="sv << final_allow_mouse << " gamepad="sv << final_allow_gamepad; + + return true; + } + + return false; + } + namespace session { std::atomic_uint running_sessions; diff --git a/src/stream.h b/src/stream.h index 3335ea78..ccbb0351 100644 --- a/src/stream.h +++ b/src/stream.h @@ -5,6 +5,7 @@ #pragma once // standard includes +#include #include #include #include @@ -31,6 +32,15 @@ namespace stream { nlohmann::json get_active_sessions_info(); + bool set_active_session_input_policy( + uint32_t session_id, + bool allow_keyboard, + bool allow_mouse, + bool allow_gamepad, + bool *effective_allow_keyboard = nullptr, + bool *effective_allow_mouse = nullptr, + bool *effective_allow_gamepad = nullptr); + struct config_t { audio::config_t audio; video::config_t monitor; diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index 94f3b5f9..13987a9b 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -151,7 +151,7 @@

Live Input Status

-

Per-session input activity and policy. Indicators update twice per second.

+

Per-session input activity and policy. Use the checkboxes to override keyboard, mouse, and gamepad permissions for each active client.

No active streaming sessions. @@ -187,6 +187,41 @@
+
+ Host override: + + + + Saving... + {{ sessionPolicyError[s.session_id] }} +
@@ -284,6 +319,8 @@ sessionInterval: null, sessionSocket: null, sessionReconnectTimer: null, + sessionPolicyBusy: {}, + sessionPolicyError: {}, restartPressed: false, showApplyMessage: false, platform: "", @@ -455,6 +492,58 @@ } }, methods: { + isSessionPolicyBusy(sessionId) { + return this.sessionPolicyBusy[sessionId] === true; + }, + updateSessionPolicy(session, field, value) { + if (!session || !session.policy || session.session_id === undefined || session.session_id === null) { + return; + } + + if (!["allow_keyboard", "allow_mouse", "allow_gamepad"].includes(field)) { + return; + } + + const sessionId = session.session_id; + const nextPolicy = { + allow_keyboard: field === "allow_keyboard" ? value : !!session.policy.allow_keyboard, + allow_mouse: field === "allow_mouse" ? value : !!session.policy.allow_mouse, + allow_gamepad: field === "allow_gamepad" ? value : !!session.policy.allow_gamepad, + }; + + this.sessionPolicyBusy[sessionId] = true; + this.sessionPolicyError[sessionId] = ""; + + fetch("./api/sessions/policy", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + session_id: sessionId, + allow_keyboard: nextPolicy.allow_keyboard, + allow_mouse: nextPolicy.allow_mouse, + allow_gamepad: nextPolicy.allow_gamepad, + }) + }) + .then((r) => r.json()) + .then((r) => { + if (!r || r.status !== true || !r.policy) { + throw new Error((r && r.error) ? r.error : "Failed to update session input policy"); + } + + session.policy.allow_keyboard = !!r.policy.allow_keyboard; + session.policy.allow_mouse = !!r.policy.allow_mouse; + session.policy.allow_gamepad = !!r.policy.allow_gamepad; + }) + .catch((e) => { + this.sessionPolicyError[sessionId] = (e && e.message) ? e.message : "Failed to update session input policy"; + this.refreshActiveSessions(); + }) + .finally(() => { + this.sessionPolicyBusy[sessionId] = false; + }); + }, connectActiveSessionsSocket() { fetch("./api/sessions/ws-token") .then((r) => r.json()) From 808d868b8e9de1394992649c5ea8d83f39705b8c Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 12 Feb 2026 09:11:55 -0700 Subject: [PATCH 19/20] Ignore non-owner client input policy updates --- src/stream.cpp | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/stream.cpp b/src/stream.cpp index d8c29690..1af48201 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -1171,12 +1171,19 @@ namespace stream { } auto *policy = (const control_set_input_policy_t *) payload.data(); - apply_session_input_policy( - session, - policy->allow_keyboard != 0, - policy->allow_mouse != 0, - policy->allow_gamepad != 0, - policy->reason); + bool is_owner_session = session->input_policy.is_owner_session.load(std::memory_order_relaxed); + if (!is_owner_session) { + BOOST_LOG(info) + << "Ignoring client input policy update from non-owner session [id="sv << session->launch_session_id + << ", client="sv << session->client_unique_id << "]"sv; + } else { + apply_session_input_policy( + session, + policy->allow_keyboard != 0, + policy->allow_mouse != 0, + policy->allow_gamepad != 0, + policy->reason); + } if (send_session_input_policy(session, INPUT_POLICY_REASON_HOST_ACK)) { BOOST_LOG(warning) << "Unable to send input policy acknowledgment"sv; From 5dd6e9214e3a4cec97f88be5e7b2cb83f55cb445 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 12 Feb 2026 12:43:52 -0700 Subject: [PATCH 20/20] Show and copy paired client UUIDs --- .../common/assets/web/troubleshooting.html | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index 13987a9b..0df895f4 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -135,10 +135,21 @@
  • -
    {{ client.name !== "" ? client.name : $t('troubleshooting.unpair_single_unknown') }}
    - +
    +
    {{ client.name !== "" ? client.name : $t('troubleshooting.unpair_single_unknown') }}
    +
    + {{ client.uuid }} +
    +
    +
    + + +
    @@ -677,6 +688,15 @@ clickedApplyBanner() { this.showApplyMessage = false; }, + copyClientUuid(uuid) { + if (!uuid) { + return; + } + + navigator.clipboard.writeText(uuid).catch(() => { + window.prompt("Copy client UUID", uuid); + }); + }, copyLogs() { // Copy the filtered view if a filter is active. navigator.clipboard.writeText(this.actualLogs);