diff --git a/src/config.cpp b/src/config.cpp index c320ed6d..371e8b24 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 @@ -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..599aba0b 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" @@ -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::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 @@ -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(); + 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. @@ -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 &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; @@ -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> 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/input.cpp b/src/input.cpp index 82c761af..908fec79 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; @@ -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 *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 @@ -442,10 +477,12 @@ namespace input { } void passthrough(std::shared_ptr &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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, + 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; + } + + 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 5b564f04..9426db71 100644 --- a/src/input.h +++ b/src/input.h @@ -24,6 +24,18 @@ 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); + + 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/nvhttp.cpp b/src/nvhttp.cpp index 4d8a87b4..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(); @@ -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(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 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/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; diff --git a/src/stream.cpp b/src/stream.cpp index b92e579e..1af48201 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" @@ -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 allow_gamepad {true}; + std::atomic allow_keyboard {false}; + std::atomic allow_mouse {false}; + std::atomic 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 +391,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; @@ -412,6 +462,55 @@ 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; + 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 + 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(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; diff --git a/src/stream.h b/src/stream.h index 53afff4f..ccbb0351 100644 --- a/src/stream.h +++ b/src/stream.h @@ -5,10 +5,14 @@ #pragma once // standard includes +#include +#include #include +#include // lib includes #include +#include // 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; diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index 222fba0e..fa7ca614 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -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 }, }, diff --git a/src_assets/common/assets/web/configs/tabs/Inputs.vue b/src_assets/common/assets/web/configs/tabs/Inputs.vue index 7fa76a20..b7f188a5 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" > @@ -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/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 c33b3db2..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 }} +
    +
    +
    + + +
    @@ -147,6 +158,84 @@
+ +
+
+

Live Input Status

+

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

+
+
+ 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' }} + + +
    +
    +
    + Host override: + + + + Saving... + {{ sessionPolicyError[s.session_id] }} +
    +
  • +
+
@@ -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); 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