Phase 2
Some checks failed
ci-bundle.yml / Phase 2 (push) Failing after 0s
ci-copr.yml / Phase 2 (push) Failing after 0s
ci-homebrew.yml / Phase 2 (push) Failing after 0s

This commit is contained in:
Joey Yakimowich-Payne 2026-02-11 13:44:02 -07:00
commit 0c16e913da
11 changed files with 277 additions and 0 deletions

View file

@ -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);

View file

@ -203,6 +203,8 @@ namespace config {
bool high_resolution_scrolling;
bool native_pen_touch;
std::vector<std::string> owner_client_uuids;
};
namespace flag {

View file

@ -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;

View file

@ -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::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()
).count();
}
static inline void touch_activity(std::atomic<uint64_t> *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<uint64_t> *activity_keyboard_ms = nullptr;
std::atomic<uint64_t> *activity_mouse_ms = nullptr;
std::atomic<uint64_t> *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 << ']';

View file

@ -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<SunshineHTTPS>(request);

View file

@ -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

View file

@ -4,6 +4,7 @@
*/
// standard includes
#include <algorithm>
#include <fstream>
#include <future>
#include <queue>
@ -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<uint64_t> last_keyboard_ms {0};
std::atomic<uint64_t> last_mouse_ms {0};
std::atomic<uint64_t> last_gamepad_ms {0};
};
/**
* @brief Return current monotonic time in milliseconds.
*/
static uint64_t now_monotonic_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
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::input_t> 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<bool>(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;

View file

@ -5,10 +5,13 @@
#pragma once
// standard includes
#include <string>
#include <utility>
#include <vector>
// lib includes
#include <boost/asio.hpp>
#include <nlohmann/json.hpp>
// 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;

View file

@ -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
},
},

View file

@ -181,6 +181,15 @@ const config = ref(props.config)
v-model="config.native_pen_touch"
default="true"
></Checkbox>
<hr>
<div class="mb-3">
<label for="owner_client_uuids" class="form-label">Owner Client UUIDs</label>
<input type="text" class="form-control" id="owner_client_uuids"
placeholder=""
v-model="config.owner_client_uuids" />
<div class="form-text">Comma-separated UUIDs of owner clients. Owner sessions start with keyboard/mouse enabled. Find UUIDs in the Troubleshooting page under paired clients.</div>
</div>
</div>
</template>

View file

@ -147,6 +147,49 @@
</li>
</ul>
</div>
<!-- Live Input Status -->
<div class="card my-4">
<div class="card-body">
<h2 id="input_status">Live Input Status</h2>
<p>Per-session input activity and policy. Indicators update every second.</p>
</div>
<div v-if="activeSessions.length === 0" class="card-body pt-0">
<em>No active streaming sessions.</em>
</div>
<ul class="list-group list-group-flush" v-else>
<li v-for="s in activeSessions" :key="s.session_id" class="list-group-item">
<div class="d-flex align-items-center justify-content-between">
<div>
<strong>{{ s.client_name || s.client_uuid || 'Unknown' }}</strong>
<span v-if="s.is_owner_session" class="badge bg-primary ms-2">Owner</span>
</div>
<div class="d-flex gap-3 align-items-center">
<span class="d-flex align-items-center gap-1" title="Keyboard">
<span :class="['activity-dot', s.activity.keyboard_active ? 'dot-green' : 'dot-gray']"></span>
KB
<span :class="['badge', s.policy.allow_keyboard ? 'bg-success' : 'bg-secondary']" style="font-size: 0.7em">
{{ s.policy.allow_keyboard ? 'ON' : 'OFF' }}
</span>
</span>
<span class="d-flex align-items-center gap-1" title="Mouse">
<span :class="['activity-dot', s.activity.mouse_active ? 'dot-green' : 'dot-gray']"></span>
Mouse
<span :class="['badge', s.policy.allow_mouse ? 'bg-success' : 'bg-secondary']" style="font-size: 0.7em">
{{ s.policy.allow_mouse ? 'ON' : 'OFF' }}
</span>
</span>
<span class="d-flex align-items-center gap-1" title="Gamepad">
<span :class="['activity-dot', s.activity.gamepad_active ? 'dot-green' : 'dot-gray']"></span>
Gamepad
<span :class="['badge', s.policy.allow_gamepad ? 'bg-success' : 'bg-secondary']" style="font-size: 0.7em">
{{ s.policy.allow_gamepad ? 'ON' : 'OFF' }}
</span>
</span>
</div>
</div>
</li>
</ul>
</div>
<!-- Logs -->
<div class="card my-4">
<div class="card-body">
@ -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);
</script>
<style>
.activity-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
transition: background-color 0.2s;
}
.dot-green {
background-color: #22c55e;
box-shadow: 0 0 4px #22c55e;
}
.dot-gray {
background-color: #6b7280;
}
</style>
</body>