Compare commits

..

20 commits

Author SHA1 Message Date
5dd6e9214e Show and copy paired client UUIDs
Some checks failed
ci-bundle.yml / Show and copy paired client UUIDs (push) Failing after 0s
ci-copr.yml / Show and copy paired client UUIDs (push) Failing after 0s
ci-homebrew.yml / Show and copy paired client UUIDs (push) Failing after 0s
2026-02-12 12:43:52 -07:00
808d868b8e Ignore non-owner client input policy updates
Some checks failed
ci-bundle.yml / Ignore non-owner client input policy updates (push) Failing after 0s
ci-copr.yml / Ignore non-owner client input policy updates (push) Failing after 0s
ci-homebrew.yml / Ignore non-owner client input policy updates (push) Failing after 0s
2026-02-12 09:11:55 -07:00
69c2693984 Add host-side per-session input policy controls
Some checks failed
ci-bundle.yml / Add host-side per-session input policy controls (push) Failing after 0s
ci-copr.yml / Add host-side per-session input policy controls (push) Failing after 0s
ci-homebrew.yml / Add host-side per-session input policy controls (push) Failing after 0s
2026-02-12 08:43:39 -07:00
7e2ef2037c Reduce troubleshooting session status polling frequency
Some checks failed
ci-bundle.yml / Reduce troubleshooting session status polling frequency (push) Failing after 0s
ci-copr.yml / Reduce troubleshooting session status polling frequency (push) Failing after 0s
ci-homebrew.yml / Reduce troubleshooting session status polling frequency (push) Failing after 0s
2026-02-12 01:36:59 -07:00
07c8a7b5fb Fix input policy control packet framing and declaration order
Some checks failed
ci-bundle.yml / Fix input policy control packet framing and declaration order (push) Failing after 0s
ci-copr.yml / Fix input policy control packet framing and declaration order (push) Failing after 0s
ci-homebrew.yml / Fix input policy control packet framing and declaration order (push) Failing after 0s
2026-02-12 00:42:02 -07:00
6fdeb1b868 Send input policy to clients on start and ack
Some checks failed
ci-bundle.yml / Send input policy to clients on start and ack (push) Failing after 0s
ci-copr.yml / Send input policy to clients on start and ack (push) Failing after 0s
ci-homebrew.yml / Send input policy to clients on start and ack (push) Failing after 0s
2026-02-12 00:38:13 -07:00
5d0a6106e6 Apply per-session input policy updates via control stream
Some checks failed
ci-bundle.yml / Apply per-session input policy updates via control stream (push) Failing after 0s
ci-copr.yml / Apply per-session input policy updates via control stream (push) Failing after 0s
ci-homebrew.yml / Apply per-session input policy updates via control stream (push) Failing after 0s
2026-02-12 00:13:42 -07:00
e2d3f23575 Clear stale input state on session reset
Some checks failed
ci-bundle.yml / Clear stale input state on session reset (push) Failing after 0s
ci-copr.yml / Clear stale input state on session reset (push) Failing after 0s
ci-homebrew.yml / Clear stale input state on session reset (push) Failing after 0s
2026-02-11 21:31:23 -07:00
1981060f09 Load live input indicator styles from shared stylesheet
Some checks failed
ci-bundle.yml / Load live input indicator styles from shared stylesheet (push) Failing after 0s
ci-copr.yml / Load live input indicator styles from shared stylesheet (push) Failing after 0s
ci-homebrew.yml / Load live input indicator styles from shared stylesheet (push) Failing after 0s
2026-02-11 16:30:43 -07:00
718c45b76a Improve visibility of live input activity dots
Some checks failed
ci-bundle.yml / Improve visibility of live input activity dots (push) Failing after 0s
ci-copr.yml / Improve visibility of live input activity dots (push) Failing after 0s
ci-homebrew.yml / Improve visibility of live input activity dots (push) Failing after 0s
2026-02-11 16:22:40 -07:00
54afa9bb67 Push live session activity over WebSocket with polling fallback
Some checks failed
ci-bundle.yml / Push live session activity over WebSocket with polling fallback (push) Failing after 0s
ci-copr.yml / Push live session activity over WebSocket with polling fallback (push) Failing after 0s
ci-homebrew.yml / Push live session activity over WebSocket with polling fallback (push) Failing after 0s
2026-02-11 16:02:49 -07:00
d5218297a4 Use dot-only live input indicators in troubleshooting page
Some checks failed
ci-bundle.yml / Use dot-only live input indicators in troubleshooting page (push) Failing after 0s
ci-copr.yml / Use dot-only live input indicators in troubleshooting page (push) Failing after 0s
ci-homebrew.yml / Use dot-only live input indicators in troubleshooting page (push) Failing after 0s
2026-02-11 15:43:30 -07:00
3f19554cfa Improve live input indicator visibility
Some checks failed
ci-bundle.yml / Improve live input indicator visibility (push) Failing after 0s
ci-copr.yml / Improve live input indicator visibility (push) Failing after 0s
ci-homebrew.yml / Improve live input indicator visibility (push) Failing after 0s
2026-02-11 15:33:10 -07:00
916eb1a213 Support Wayland monitor selection by connector name
Some checks failed
ci-bundle.yml / Support Wayland monitor selection by connector name (push) Failing after 0s
ci-copr.yml / Support Wayland monitor selection by connector name (push) Failing after 0s
ci-homebrew.yml / Support Wayland monitor selection by connector name (push) Failing after 0s
2026-02-11 15:25:48 -07:00
c4315144b3 Avoid broadcast lifecycle races in active sessions API
Some checks failed
ci-bundle.yml / Avoid broadcast lifecycle races in active sessions API (push) Failing after 0s
ci-copr.yml / Avoid broadcast lifecycle races in active sessions API (push) Failing after 0s
ci-homebrew.yml / Avoid broadcast lifecycle races in active sessions API (push) Failing after 0s
2026-02-11 15:02:53 -07:00
a271a63602 Fix crash: check joinable() before thread join in end_broadcast
Some checks failed
ci-bundle.yml / Fix crash: check joinable() before thread join in end_broadcast (push) Failing after 0s
ci-copr.yml / Fix crash: check joinable() before thread join in end_broadcast (push) Failing after 0s
ci-homebrew.yml / Fix crash: check joinable() before thread join in end_broadcast (push) Failing after 0s
2026-02-11 14:46:56 -07:00
0f070b144f Fix client UUID resolution from SSL peer certificate
Some checks failed
ci-bundle.yml / Fix client UUID resolution from SSL peer certificate (push) Failing after 0s
ci-copr.yml / Fix client UUID resolution from SSL peer certificate (push) Failing after 0s
ci-homebrew.yml / Fix client UUID resolution from SSL peer certificate (push) Failing after 0s
2026-02-11 14:39:37 -07:00
6c3a91357c Fix compilation
Some checks failed
ci-bundle.yml / Fix compilation (push) Failing after 0s
ci-copr.yml / Fix compilation (push) Failing after 0s
ci-homebrew.yml / Fix compilation (push) Failing after 0s
2026-02-11 13:59:42 -07:00
0c16e913da 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
2026-02-11 13:44:02 -07:00
bbc1f724e1 Phase 1
Some checks failed
ci-bundle.yml / Phase 1 (push) Failing after 0s
ci-copr.yml / Phase 1 (push) Failing after 0s
ci-homebrew.yml / Phase 1 (push) Failing after 0s
2026-02-10 06:34:17 -07:00
15 changed files with 1120 additions and 49 deletions

View file

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

@ -7,10 +7,18 @@
#define BOOST_BIND_GLOBAL_PLACEHOLDERS
// standard includes
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <filesystem>
#include <format>
#include <fstream>
#include <mutex>
#include <set>
#include <thread>
#include <unordered_map>
#include <vector>
// lib includes
#include <boost/algorithm/string.hpp>
@ -19,6 +27,8 @@
#include <nlohmann/json.hpp>
#include <Simple-Web-Server/crypto.hpp>
#include <Simple-Web-Server/server_https.hpp>
#include <openssl/evp.h>
#include <openssl/sha.h>
#ifdef _WIN32
#include "platform/windows/misc.h"
@ -40,6 +50,7 @@
#include "nvhttp.h"
#include "platform/common.h"
#include "process.h"
#include "stream.h"
#include "utility.h"
#include "uuid.h"
@ -54,6 +65,119 @@ namespace confighttp {
using resp_https_t = std::shared_ptr<typename SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response>;
using req_https_t = std::shared_ptr<typename SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request>;
namespace {
struct ws_client_t {
std::unique_ptr<SimpleWeb::HTTPS> 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<std::shared_ptr<ws_client_t>> ws_clients;
std::mutex ws_tokens_mutex;
std::unordered_map<std::string, std::chrono::steady_clock::time_point> 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<unsigned char, SHA_DIGEST_LENGTH> digest {};
SHA1(reinterpret_cast<const unsigned char *>(input.data()), input.size(), digest.data());
std::array<unsigned char, 4 * ((SHA_DIGEST_LENGTH + 2) / 3) + 1> encoded {};
auto len = EVP_EncodeBlock(encoded.data(), digest.data(), digest.size());
return std::string(reinterpret_cast<const char *>(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<char>(0x81));
auto len = payload.size();
if (len <= 125) {
frame.push_back(static_cast<char>(len));
} else if (len <= 0xFFFF) {
frame.push_back(static_cast<char>(126));
frame.push_back(static_cast<char>((len >> 8) & 0xFF));
frame.push_back(static_cast<char>(len & 0xFF));
} else {
frame.push_back(static_cast<char>(127));
for (int i = 7; i >= 0; --i) {
frame.push_back(static_cast<char>((len >> (i * 8)) & 0xFF));
}
}
frame.append(payload);
return frame;
}
void ws_write_http_response(std::unique_ptr<SimpleWeb::HTTPS> &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
@ -136,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.
@ -167,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;
}
@ -805,6 +935,93 @@ 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);
}
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);
}
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<uint32_t>();
const bool allow_keyboard = input_tree.at("allow_keyboard").get<bool>();
const bool allow_mouse = input_tree.at("allow_mouse").get<bool>();
const bool allow_gamepad = input_tree.at("allow_gamepad").get<bool>();
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.
@ -1454,12 +1671,74 @@ 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/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;
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<SimpleWeb::HTTPS> &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<ws_client_t>();
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;
@ -1483,11 +1762,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<std::shared_ptr<ws_client_t>> 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

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;
@ -172,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
@ -193,8 +208,28 @@ 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;
std::atomic<bool> allow_keyboard;
std::atomic<bool> allow_mouse;
std::atomic<bool> allow_gamepad;
};
static inline bool keyboard_allowed(const std::shared_ptr<input_t> &input) {
return config::input.keyboard && input->allow_keyboard.load(std::memory_order_relaxed);
}
static inline bool mouse_allowed(const std::shared_ptr<input_t> &input) {
return config::input.mouse && input->allow_mouse.load(std::memory_order_relaxed);
}
static inline bool gamepad_allowed(const std::shared_ptr<input_t> &input) {
return config::input.controller && input->allow_gamepad.load(std::memory_order_relaxed);
}
/**
* @brief Apply shortcut based on VKEY
* @param keyCode The VKEY code
@ -442,10 +477,12 @@ namespace input {
}
void passthrough(std::shared_ptr<input_t> &input, PNV_REL_MOUSE_MOVE_PACKET packet) {
if (!config::input.mouse) {
if (!mouse_allowed(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));
}
@ -539,10 +576,12 @@ namespace input {
}
void passthrough(std::shared_ptr<input_t> &input, PNV_ABS_MOUSE_MOVE_PACKET packet) {
if (!config::input.mouse) {
if (!mouse_allowed(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;
}
@ -589,10 +628,12 @@ namespace input {
}
void passthrough(std::shared_ptr<input_t> &input, PNV_MOUSE_BUTTON_PACKET packet) {
if (!config::input.mouse) {
if (!mouse_allowed(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()) {
@ -753,10 +794,12 @@ namespace input {
}
void passthrough(std::shared_ptr<input_t> &input, PNV_KEYBOARD_PACKET packet) {
if (!config::input.keyboard) {
if (!keyboard_allowed(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;
@ -813,10 +856,12 @@ namespace input {
* @param packet The scroll packet.
*/
void passthrough(std::shared_ptr<input_t> &input, PNV_SCROLL_PACKET packet) {
if (!config::input.mouse) {
if (!mouse_allowed(input)) {
return;
}
touch_activity(input->activity_mouse_ms);
if (config::input.high_resolution_scrolling) {
platf::scroll(platf_input, util::endian::big(packet->scrollAmt1));
} else {
@ -836,10 +881,12 @@ namespace input {
* @param packet The scroll packet.
*/
void passthrough(std::shared_ptr<input_t> &input, PSS_HSCROLL_PACKET packet) {
if (!config::input.mouse) {
if (!mouse_allowed(input)) {
return;
}
touch_activity(input->activity_mouse_ms);
if (config::input.high_resolution_scrolling) {
platf::hscroll(platf_input, util::endian::big(packet->scrollAmount));
} else {
@ -853,8 +900,8 @@ namespace input {
}
}
void passthrough(PNV_UNICODE_PACKET packet) {
if (!config::input.keyboard) {
void passthrough(std::shared_ptr<input_t> &input, PNV_UNICODE_PACKET packet) {
if (!keyboard_allowed(input)) {
return;
}
@ -868,10 +915,12 @@ namespace input {
* @param packet The controller arrival packet.
*/
void passthrough(std::shared_ptr<input_t> &input, PSS_CONTROLLER_ARRIVAL_PACKET packet) {
if (!config::input.controller) {
if (!gamepad_allowed(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;
@ -908,10 +957,12 @@ namespace input {
* @param packet The touch packet.
*/
void passthrough(std::shared_ptr<input_t> &input, PSS_TOUCH_PACKET packet) {
if (!config::input.mouse) {
if (!mouse_allowed(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) {
@ -964,10 +1015,12 @@ namespace input {
* @param packet The pen packet.
*/
void passthrough(std::shared_ptr<input_t> &input, PSS_PEN_PACKET packet) {
if (!config::input.mouse) {
if (!mouse_allowed(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) {
@ -1022,10 +1075,12 @@ namespace input {
* @param packet The controller touch packet.
*/
void passthrough(std::shared_ptr<input_t> &input, PSS_CONTROLLER_TOUCH_PACKET packet) {
if (!config::input.controller) {
if (!gamepad_allowed(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;
@ -1055,10 +1110,12 @@ namespace input {
* @param packet The controller motion packet.
*/
void passthrough(std::shared_ptr<input_t> &input, PSS_CONTROLLER_MOTION_PACKET packet) {
if (!config::input.controller) {
if (!gamepad_allowed(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;
@ -1087,10 +1144,12 @@ namespace input {
* @param packet The controller battery packet.
*/
void passthrough(std::shared_ptr<input_t> &input, PSS_CONTROLLER_BATTERY_PACKET packet) {
if (!config::input.controller) {
if (!gamepad_allowed(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;
@ -1112,10 +1171,12 @@ namespace input {
}
void passthrough(std::shared_ptr<input_t> &input, PNV_MULTI_CONTROLLER_PACKET packet) {
if (!config::input.controller) {
if (!gamepad_allowed(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 << ']';
@ -1578,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);
@ -1626,8 +1687,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) {
@ -1638,7 +1699,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 {
@ -1680,4 +1744,24 @@ namespace input {
return input;
}
void set_activity_pointers(
std::shared_ptr<input_t> &input,
std::atomic<uint64_t> *keyboard_ms,
std::atomic<uint64_t> *mouse_ms,
std::atomic<uint64_t> *gamepad_ms) {
input->activity_keyboard_ms = keyboard_ms;
input->activity_mouse_ms = mouse_ms;
input->activity_gamepad_ms = gamepad_ms;
}
void set_policy(
std::shared_ptr<input_t> &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

View file

@ -24,6 +24,18 @@ namespace input {
std::shared_ptr<input_t> alloc(safe::mail_t mail);
void set_activity_pointers(
std::shared_ptr<input_t> &input,
std::atomic<uint64_t> *keyboard_ms,
std::atomic<uint64_t> *mouse_ms,
std::atomic<uint64_t> *gamepad_ms);
void set_policy(
std::shared_ptr<input_t> &input,
bool allow_keyboard,
bool allow_mouse,
bool allow_gamepad);
struct touch_port_t: public platf::touch_port_t {
int env_width, env_height;

View file

@ -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<rtsp_stream::launch_session_t> make_launch_session(bool host_audio, const args_t &args) {
auto launch_session = std::make_shared<rtsp_stream::launch_session_t>();
@ -782,6 +829,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);
@ -859,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;
@ -967,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

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

@ -3,6 +3,7 @@
* @brief Definitions for wlgrab capture.
*/
// standard includes
#include <algorithm>
#include <thread>
// 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;

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"
@ -49,6 +51,12 @@ 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
#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
@ -67,6 +75,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;
@ -203,6 +212,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;
@ -340,6 +356,34 @@ namespace stream {
control_server_t control_server;
};
/**
* @brief Per-session input policy (which remote input types are allowed).
*/
struct session_input_policy_t {
std::atomic<bool> allow_gamepad {true};
std::atomic<bool> allow_keyboard {false};
std::atomic<bool> allow_mouse {false};
std::atomic<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 +391,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;
@ -412,6 +462,55 @@ namespace stream {
std::atomic<session::state_e> state;
};
template<std::size_t max_payload_size>
static inline std::string_view encode_control(session_t *session, const std::string_view &plaintext, std::array<std::uint8_t, max_payload_size> &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;
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;
}
static int send_session_input_policy(session_t *session, uint8_t reason) {
if (!session->control.peer) {
return -1;
}
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.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<std::uint8_t, sizeof(control_encrypted_t) + crypto::cipher::round_to_pkcs7_padded(sizeof(plaintext)) + crypto::cipher::tag_size>
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
*
@ -930,6 +1029,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) {
@ -1059,6 +1162,34 @@ 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();
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;
}
});
// This thread handles latency-sensitive control messages
platf::set_thread_name("stream::controlBroadcast");
platf::adjust_thread_priority(platf::thread_priority_e::critical);
@ -1787,13 +1918,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();
@ -1896,6 +2027,114 @@ 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;
}
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.load(std::memory_order_relaxed);
entry["policy"] = {
{"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;
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 ? (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));
}
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;
@ -1964,6 +2203,19 @@ namespace stream {
int start(session_t &session, const std::string &addr_string) {
session.input = input::alloc(session.mail);
input::set_activity_pointers(
session.input,
&session.input_activity.last_keyboard_ms,
&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;
@ -2010,6 +2262,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.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;
}
session->config = config;

View file

@ -5,10 +5,14 @@
#pragma once
// standard includes
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
// lib includes
#include <boost/asio.hpp>
#include <nlohmann/json.hpp>
// local includes
#include "audio.h"
@ -20,8 +24,23 @@ namespace stream {
constexpr auto CONTROL_PORT = 10;
constexpr auto AUDIO_STREAM_PORT = 11;
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;
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;

View file

@ -203,14 +203,15 @@
"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",
"owner_client_uuids": "",
"keybindings": "[0x10,0xA0,0x11,0xA2,0x12,0xA4]", // todo: add this to UI
},
},

View file

@ -118,7 +118,7 @@ const config = ref(props.config)
id="keyboard"
locale-prefix="config"
v-model="config.keyboard"
default="true"
default="false"
></Checkbox>
<!-- Key Repeat Delay-->
@ -161,7 +161,7 @@ const config = ref(props.config)
id="mouse"
locale-prefix="config"
v-model="config.mouse"
default="true"
default="false"
></Checkbox>
<!-- High resolution scrolling support -->
@ -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

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

View file

@ -135,10 +135,21 @@
</div>
<ul class="list-group list-group-flush" v-if="clients && clients.length > 0">
<li v-for="client in clients" :key="client.uuid" class="list-group-item d-flex align-items-center">
<div class="flex-grow-1">{{ client.name !== "" ? client.name : $t('troubleshooting.unpair_single_unknown') }}</div>
<button class="btn btn-danger ms-auto" @click="unpairSingle(client.uuid)">
<trash-2 :size="18" class="icon"></trash-2>
</button>
<div class="flex-grow-1">
<div>{{ client.name !== "" ? client.name : $t('troubleshooting.unpair_single_unknown') }}</div>
<div class="text-muted" style="font-size: 0.8em">
<span class="font-monospace">{{ client.uuid }}</span>
</div>
</div>
<div class="d-flex align-items-center gap-2 ms-auto">
<button class="btn btn-outline-secondary btn-sm" @click="copyClientUuid(client.uuid)">
<copy :size="16" class="icon"></copy>
Copy UUID
</button>
<button class="btn btn-danger" @click="unpairSingle(client.uuid)">
<trash-2 :size="18" class="icon"></trash-2>
</button>
</div>
</li>
</ul>
<ul v-else class="list-group list-group-flush">
@ -147,6 +158,84 @@
</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. Use the checkboxes to override keyboard, mouse, and gamepad permissions for each active client.</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>
<div class="d-flex align-items-center flex-wrap gap-3 mt-2">
<span class="text-muted" style="font-size: 0.8em">Host override:</span>
<label class="d-flex align-items-center gap-1" style="font-size: 0.8em">
<input
class="form-check-input mt-0"
type="checkbox"
:checked="s.policy.allow_keyboard"
:disabled="isSessionPolicyBusy(s.session_id)"
@change="updateSessionPolicy(s, 'allow_keyboard', $event.target.checked)"
>
KB
</label>
<label class="d-flex align-items-center gap-1" style="font-size: 0.8em">
<input
class="form-check-input mt-0"
type="checkbox"
:checked="s.policy.allow_mouse"
:disabled="isSessionPolicyBusy(s.session_id)"
@change="updateSessionPolicy(s, 'allow_mouse', $event.target.checked)"
>
Mouse
</label>
<label class="d-flex align-items-center gap-1" style="font-size: 0.8em">
<input
class="form-check-input mt-0"
type="checkbox"
:checked="s.policy.allow_gamepad"
:disabled="isSessionPolicyBusy(s.session_id)"
@change="updateSessionPolicy(s, 'allow_gamepad', $event.target.checked)"
>
Gamepad
</label>
<span v-if="isSessionPolicyBusy(s.session_id)" class="text-muted" style="font-size: 0.8em">Saving...</span>
<span v-else-if="sessionPolicyError[s.session_id]" class="text-danger" style="font-size: 0.8em">{{ sessionPolicyError[s.session_id] }}</span>
</div>
</li>
</ul>
</div>
<!-- Logs -->
<div class="card my-4">
<div class="card-body">
@ -229,6 +318,7 @@
},
data() {
return {
activeSessions: [],
clients: [],
closeAppPressed: false,
closeAppStatus: null,
@ -237,6 +327,11 @@
logs: 'Loading...',
logFilter: null,
logInterval: null,
sessionInterval: null,
sessionSocket: null,
sessionReconnectTimer: null,
sessionPolicyBusy: {},
sessionPolicyError: {},
restartPressed: false,
showApplyMessage: false,
platform: "",
@ -389,13 +484,140 @@
this.logInterval = setInterval(() => {
this.refreshLogs();
}, 5000);
this.sessionInterval = setInterval(() => {
this.refreshActiveSessions();
}, 5000);
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: {
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())
.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) => {
if (r.status === true && r.sessions) {
this.activeSessions = r.sessions;
} else {
this.activeSessions = [];
}
})
.catch(() => {
this.activeSessions = [];
});
},
refreshLogs() {
fetch("./api/logs",)
.then((r) => r.text())
@ -466,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);

@ -1 +1 @@
Subproject commit 546895a93a29062bb178367b46c7afb72da9881e
Subproject commit 99c1f621ebd8d119c5d2dc3a88ecf255058acec0