#include "remote_session.h" #include #include #include #include #include namespace remote_session { namespace { std::mutex monitor_runtime_hooks_mutex; monitor_runtime_hooks_t monitor_runtime_hooks; // Moonlight refreshes the app catalogue after the first synthetic launch // failure before the user can launch Terminate again. That round trip can // exceed ten seconds on mobile clients even when the second launch is // immediate from the user's perspective. constexpr auto terminate_confirmation_window = std::chrono::seconds {60}; struct terminate_confirmation_t { std::uint64_t generation {}; std::int32_t app_id {}; std::chrono::steady_clock::time_point expires_at {}; }; std::mutex terminate_confirmation_mutex; std::unordered_map terminate_confirmations; struct app_replacement_confirmation_t { std::uint64_t generation {}; std::int32_t requested_app_id {}; std::chrono::steady_clock::time_point expires_at {}; }; std::mutex app_replacement_confirmation_mutex; std::unordered_map app_replacement_confirmations; bool equal_folded(std::string_view left, std::string_view right) { if (left.size() != right.size()) return false; for (std::size_t i = 0; i < left.size(); ++i) { if (std::tolower(static_cast(left[i])) != std::tolower(static_cast(right[i]))) return false; } return true; } bool contains(const std::vector &values, std::string_view value) { return std::find(values.begin(), values.end(), value) != values.end(); } bool reserved_synthetic_id(const std::int32_t id) { return (id >= resume_id && id <= running_game_id) || id == secondary_resume_id || id == secondary_terminate_id || id == secondary_monitor_id || id == secondary_input_id || (id >= 2147483601 && id <= 2147483606); } std::string ranked_title(const std::string_view rank, const std::string_view title) { return std::string {rank} + std::string {title}; } app_t prioritized_secondary_control(const control_e control) { auto app = synthetic(control); switch (control) { case control_e::monitor: app.id = secondary_monitor_id; app.title = ranked_title(" ", app.title); break; case control_e::input: app.id = secondary_input_id; app.title = ranked_title(" ", app.title); break; case control_e::resume: app.id = secondary_resume_id; app.title = ranked_title(" ", app.title); break; case control_e::terminate: app.id = secondary_terminate_id; app.title = ranked_title(" ", app.title); break; default: break; } return app; } bool owns_game(const caller_t &caller, const game_t &game) { return game.running && caller.paired && caller.uuid == game.owner_uuid; } } bool reserved_name(const std::string_view name) { const auto first_visible = name.find_first_not_of(" \t\r\n"); const auto visible_name = first_visible == std::string_view::npos ? std::string_view {} : name.substr(first_visible); return equal_folded(visible_name, "Remote Input") || equal_folded(visible_name, "Remote Monitor") || equal_folded(visible_name, "Virtual Display") || equal_folded(visible_name, "Terminate"); } control_e identify(const std::int32_t id, const std::string_view uuid) { if (id == secondary_resume_id) return control_e::resume; if (id == secondary_terminate_id) return control_e::terminate; if (id == secondary_monitor_id) return control_e::monitor; if (id == secondary_input_id) return control_e::input; if (id == resume_id || id == 2147483601 || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000001") return control_e::resume; if (id == disconnect_monitor_id || id == 2147483602 || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000002") return control_e::disconnect_monitor; if (id == disconnect_input_id || id == 2147483603 || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000003") return control_e::disconnect_input; if (id == terminate_id || id == 2147483604 || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000004") return control_e::terminate; if (id == monitor_id || id == 2147483605 || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000005") return control_e::monitor; if (id == input_id || id == 2147483606 || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000006") return control_e::input; if (id == running_game_id || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000007") return control_e::running_game; return control_e::none; } control_e identify(const std::int32_t id, const std::string_view uuid, const std::int32_t running_app_id) { const auto control = identify(id, uuid); if (control != control_e::none) { return control; } return running_app_id > 0 && id == synthetic_running_game_id(running_app_id) ? control_e::running_game : control_e::none; } std::string synthetic_uuid(const control_e control) { const auto suffix = static_cast(control); return suffix >= 1 && suffix <= 7 ? "9a1c5a25-58fe-40e0-b9aa-7d3f0000000" + std::to_string(suffix) : std::string {}; } app_t synthetic(const control_e control) { switch (control) { case control_e::resume: return {resume_id, synthetic_uuid(control), "Resume", true}; case control_e::disconnect_monitor: return {disconnect_monitor_id, synthetic_uuid(control), "Disconnect Monitor", true}; case control_e::disconnect_input: return {disconnect_input_id, synthetic_uuid(control), "Disconnect Input", true}; case control_e::terminate: return {terminate_id, synthetic_uuid(control), "Terminate", true}; case control_e::monitor: return {monitor_id, synthetic_uuid(control), "Remote Monitor", true}; case control_e::input: return {input_id, synthetic_uuid(control), "Remote Input", true}; default: return {}; } } std::int32_t synthetic_running_game_id(const std::int32_t app_id) { // Toggle a high value bit so the resume-only identity remains stable for // one app/art revision but changes with the configured app identity. Skip // control IDs so the contextual running-game check cannot be shadowed by // an ordinary synthetic-control lookup. constexpr std::uint32_t positive_id_mask = 0x7fffffffU; constexpr std::uint32_t identity_salt = 0x40000000U; const auto source = static_cast(app_id) & positive_id_mask; auto candidate = (source ^ identity_salt) & positive_id_mask; while (candidate == 0 || candidate == source || reserved_synthetic_id(static_cast(candidate))) { candidate = candidate == positive_id_mask ? 1 : candidate + 1; } return static_cast(candidate); } app_t synthetic_running_game(const app_t &game) { // Moonlight clients commonly alphabetize the received catalogue. Ranked // leading spaces keep this resume-only duplicate first, followed by // Remote Monitor, Remote Input, Resume, and Terminate, without changing // visible labels. return { synthetic_running_game_id(game.id), synthetic_uuid(control_e::running_game), ranked_title(" ", game.title), true, }; } std::optional synthetic_artwork_filename(const control_e control) { switch (control) { case control_e::resume: return "resume.png"; case control_e::disconnect_monitor: return "disconnect-remote-monitor.png"; case control_e::disconnect_input: return "disconnect-remote-input.png"; case control_e::terminate: return "terminate.png"; case control_e::monitor: return "remote-monitor.png"; case control_e::input: return "remote-input.png"; default: return std::nullopt; } } bool exposes_active_game( const caller_t &caller, const game_t &game, const owner_t &owner, const bool remote_sessions_active, const bool replacement_confirmation_active ) { if (!game.running) return false; if (replacement_confirmation_active) return true; // Any paired client that may view the host is told what the host is actually doing. // // Upstream 2.0.0 restricted this to the session's own owner, which left every OTHER // ArtMoon looking at a host that claimed to be free while it was mid-stream: no // running-app card, no STREAMING badge, no Resume/Stop — on a session the host would // happily hand over. Nik's rule (2026-10-10): any device looking at that PC sees what // is happening on it. What an active game is doing is a fact about the HOST, not about // the caller, so it is not the owner's private information. // // Kept unexposed: no game running, a caller without view permission, and a caller holding // a role that is IN the session it would be told about. // // A retained Remote Monitor is the exception, and it is not a small one: the monitor is a // second session beside the game, so its holder is exactly the person most likely to be // looking at the list while something runs. Excluding it meant the badge vanished at the // moment the second screen came up - the one row you needed to read, with no way to read // it. The monitor owns no part of the game (that is what role_e::game is), so telling it // what the game is doing leaks nothing it cannot already see: the whole catalogue, and // Resume. if (owner.role != role_e::none && owner.role != role_e::monitor) return false; return caller.paired && caller.may_view; } bool allows_normal_game_cancel(const caller_t &caller, const game_t &game, const bool remote_sessions_active) { return game.running && caller.paired && caller.may_terminate && (!remote_sessions_active || owns_game(caller, game)); } projection_t project(const caller_t &caller, const game_t &game, const owner_t &owner, const std::vector &configured, const bool remote_sessions_active) { projection_t result; std::vector visible_configured; std::copy_if(configured.begin(), configured.end(), std::back_inserter(visible_configured), [](const app_t &app) { const auto control = identify(0, app.uuid); return control != control_e::input && control != control_e::monitor && !reserved_name(app.title); }); if (owner.role == role_e::monitor) { // Holding a Remote Monitor used to collapse the whole catalogue to these two entries. That // was honest while leaving a monitor ended its role: the collapse lasted exactly as long as // the second screen was streaming. Leaving no longer ends the role (4 Nov 2026), so the // collapse outlived the stream, and for as long as the second screen existed the client saw // nothing but Resume and Disconnect Monitor - no way back to a game, and no way to start one. // // The monitor's own two controls go on the front; under them the caller sees exactly the list // it would see holding no role at all. The game and the monitor are separate sessions, and // running one while the other is up is the point of the feature. // // A catalogue that already carries a resume entry keeps it: two resume tiles on one screen // would differ only by which stream they reopen, which is not something a list can say. auto as_if_idle = owner; as_if_idle.role = role_e::none; result = project(caller, game, as_if_idle, configured, remote_sessions_active); // Remote Input is refused while this client holds a role (dispatch allows it only from // role none), so offering the tile would be a button that cannot act. result.catalogue.erase( std::remove_if(result.catalogue.begin(), result.catalogue.end(), [](const app_t &entry) { return identify(entry.id, entry.uuid) == control_e::input; }), result.catalogue.end() ); const auto already_resumable = std::any_of(result.catalogue.begin(), result.catalogue.end(), [](const app_t &entry) { const auto control = identify(entry.id, entry.uuid); return control == control_e::resume || control == control_e::running_game; }); result.catalogue.insert(result.catalogue.begin(), synthetic(control_e::disconnect_monitor)); if (!already_resumable) result.catalogue.insert(result.catalogue.begin(), synthetic(control_e::resume)); return result; } if (!game.running && !remote_sessions_active && owner.role == role_e::none) { result.catalogue = std::move(visible_configured); result.catalogue.push_back(synthetic(control_e::input)); result.catalogue.push_back(synthetic(control_e::monitor)); return result; } if (owns_game(caller, game)) { result.free = false; result.current_game = game.app.id; result.catalogue = visible_configured; if (owner.role != role_e::input) result.catalogue.push_back(synthetic(control_e::input)); result.catalogue.push_back(synthetic(control_e::monitor)); return result; } if (game.running) { // Secondary clients are told the host is free so they can choose any // configured app. Keep that complete catalogue, but add a distinct, // resume-only copy of the active game at the front. The configured copy // remains available under its normal identity. // // `free` here is a statement about THIS CALLER's launchability, not about the host // being idle: a secondary client may launch any configured app, and the host's // takeover flow decides what happens to the running session. What the host is // actually doing goes in serverinfo via exposes_active_game(). Do not conflate them. result.catalogue = { synthetic_running_game(game.app), prioritized_secondary_control(control_e::resume), prioritized_secondary_control(control_e::terminate), }; result.catalogue.insert(result.catalogue.end(), visible_configured.begin(), visible_configured.end()); if (owner.role != role_e::input) result.catalogue.push_back(prioritized_secondary_control(control_e::input)); result.catalogue.push_back(prioritized_secondary_control(control_e::monitor)); return result; } result.catalogue = visible_configured; if (owner.role != role_e::input) result.catalogue.push_back(synthetic(control_e::input)); result.catalogue.push_back(synthetic(control_e::monitor)); return result; } dispatch_t dispatch(const caller_t &caller, const game_t &game, const owner_t &owner, const control_e control) { dispatch_t result {.control = control}; switch (control) { case control_e::resume: case control_e::running_game: result.permission = permission_e::view; result.allowed = caller.may_view && (game.running || owner.role == role_e::monitor); result.resume = result.allowed; if (result.allowed) { result.resume_role = owner.role == role_e::monitor ? role_e::monitor : role_e::game; } break; case control_e::input: result.permission = permission_e::launch; result.allowed = caller.may_launch && owner.role == role_e::none; break; case control_e::monitor: result.permission = permission_e::launch; // Moonlight may retry a slow /launch request or invoke a cached Remote // Monitor tile after ownership has already been published. For the // same paired client this is an idempotent activation retry, not a // permission failure. Another client's owner is never passed here. result.allowed = caller.may_launch && (owner.role == role_e::none || owner.role == role_e::monitor); result.resume = result.allowed && owner.role == role_e::monitor; if (result.resume) result.resume_role = role_e::monitor; break; case control_e::terminate: result.permission = permission_e::terminate; result.allowed = caller.may_terminate && game.running; result.terminate = result.allowed; break; case control_e::disconnect_monitor: result.permission = permission_e::terminate; result.allowed = caller.may_terminate && (owner.role == role_e::monitor || owner.role == role_e::none); result.already_complete = result.allowed && owner.role == role_e::none; break; case control_e::disconnect_input: result.permission = permission_e::terminate; result.allowed = caller.may_terminate && (owner.role == role_e::input || owner.role == role_e::none); result.already_complete = result.allowed && owner.role == role_e::none; break; default: break; } return result; } bool joins_existing_game_output( const role_e role, const bool stream_active, const bool retained_output_ready ) { return role == role_e::game && (stream_active || retained_output_ready); } bool is_secondary_game_client(const std::string_view app_owner_uuid, const std::string_view caller_uuid) { return !app_owner_uuid.empty() && app_owner_uuid != caller_uuid; } std::string_view stream_start_response_key(const bool launched_from_applist) { return launched_from_applist ? "gamesession" : "resume"; } std::optional successful_control_completion(const control_e control) { // These entries are host controls presented through Moonlight's launch UI, // not streamable applications. Apollo established 410 as the deliberate // launch failure that completes the action and displays its message instead // of making Moonlight wait for a nonexistent RTSP session URL. switch (control) { case control_e::disconnect_monitor: return control_completion_t {410, "Remote Monitor disconnected successfully."}; case control_e::disconnect_input: return control_completion_t {410, "Remote Input disconnected successfully."}; case control_e::terminate: return control_completion_t {410, "Active stream terminated. Remote Monitor and Remote Input remain connected."}; default: return std::nullopt; } } bool requires_termination_confirmation(const bool terminate_on_first_request, const bool caller_owns_active_game) { // The original game client is not affected by the extra-client safety // guard. The setting only opts secondary clients into the one-request path. return !terminate_on_first_request && !caller_owns_active_game; } terminate_confirmation_e arm_or_confirm_termination( const std::string_view client_uuid, const std::uint64_t generation, const std::int32_t app_id, const std::chrono::steady_clock::time_point now ) { std::lock_guard lock {terminate_confirmation_mutex}; const auto it = terminate_confirmations.find(std::string {client_uuid}); if (it != terminate_confirmations.end()) { const auto pending = it->second; terminate_confirmations.erase(it); if (pending.generation == generation && pending.app_id == app_id && pending.expires_at > now) { return terminate_confirmation_e::confirmed; } } terminate_confirmations.insert_or_assign( std::string {client_uuid}, terminate_confirmation_t {generation, app_id, now + terminate_confirmation_window} ); return terminate_confirmation_e::prompt; } std::string_view termination_confirmation_message() { return "This will close the active stream but leave Remote Monitor and Remote Input connected. Launch Terminate again within 60 seconds to confirm this was intentional."; } void clear_termination_confirmation(const std::string_view client_uuid) { std::lock_guard lock {terminate_confirmation_mutex}; terminate_confirmations.erase(std::string {client_uuid}); } app_replacement_confirmation_e arm_or_confirm_app_replacement( const std::string_view client_uuid, const std::uint64_t generation, const std::int32_t requested_app_id, const std::chrono::steady_clock::time_point now ) { std::lock_guard lock {app_replacement_confirmation_mutex}; const auto it = app_replacement_confirmations.find(std::string {client_uuid}); if (it != app_replacement_confirmations.end()) { const auto pending = it->second; app_replacement_confirmations.erase(it); if (pending.generation == generation && pending.requested_app_id == requested_app_id && pending.expires_at > now) { return app_replacement_confirmation_e::confirmed; } } app_replacement_confirmations.insert_or_assign( std::string {client_uuid}, app_replacement_confirmation_t {generation, requested_app_id, now + terminate_confirmation_window} ); return app_replacement_confirmation_e::prompt; } bool app_replacement_confirmation_active( const std::string_view client_uuid, const std::uint64_t generation, const std::chrono::steady_clock::time_point now ) { std::lock_guard lock {app_replacement_confirmation_mutex}; const auto it = app_replacement_confirmations.find(std::string {client_uuid}); if (it == app_replacement_confirmations.end()) return false; if (it->second.generation == generation && it->second.expires_at > now) return true; app_replacement_confirmations.erase(it); return false; } std::string_view app_replacement_confirmation_message() { return "An app is already running. Launch this app again within 60 seconds to confirm that you want to close it."; } void clear_app_replacement_confirmation(const std::string_view client_uuid) { std::lock_guard lock {app_replacement_confirmation_mutex}; app_replacement_confirmations.erase(std::string {client_uuid}); } bool input_uses_display_or_audio(const role_e role) { return role != role_e::input; } bool uses_audio(const role_e role, const bool mute_remote_monitor) { return role != role_e::input && !(role == role_e::monitor && mute_remote_monitor); } bool uses_host_audio(const role_e role) { return role == role_e::monitor; } bool disconnect_monitor_after_stream( const bool disconnect_on_stream_end, const bool disconnect_on_client_disconnect, const bool client_disconnected ) { return disconnect_on_stream_end || (disconnect_on_client_disconnect && client_disconnected); } capture_plan_t capture_plan(const role_e role, std::optional output) { if (role == role_e::input) { return {.source = capture_source_e::synthetic_black}; } if (role == role_e::monitor) { if (!output || output->empty()) { return {.source = capture_source_e::invalid}; } return {.source = capture_source_e::exact_output, .output = std::move(output)}; } return {.source = capture_source_e::active_output}; } void register_monitor_runtime_hooks(monitor_runtime_hooks_t hooks) { std::lock_guard lock {monitor_runtime_hooks_mutex}; monitor_runtime_hooks = std::move(hooks); } monitor_runtime_state_t activate_or_resume_monitor(const std::string_view client_uuid, const std::string_view client_label, const std::string_view requested_mode, const bool hdr_requested, const std::uint64_t generation) { std::function activate; { std::lock_guard lock {monitor_runtime_hooks_mutex}; activate = monitor_runtime_hooks.activate_or_resume; } return activate ? activate(client_uuid, client_label, requested_mode, hdr_requested, generation) : monitor_runtime_state_t {.retryable = true, .error = "Remote Monitor topology is not ready."}; } monitor_runtime_state_t monitor_runtime_snapshot(const std::string_view client_uuid, const std::uint64_t generation) { std::function snapshot; { std::lock_guard lock {monitor_runtime_hooks_mutex}; snapshot = monitor_runtime_hooks.snapshot; } return snapshot ? snapshot(client_uuid, generation) : monitor_runtime_state_t {.retryable = true, .error = "Remote Monitor topology is not ready."}; } void release_monitor(const std::string_view client_uuid, const std::uint64_t generation, const std::string_view reason) { std::function release; { std::lock_guard lock {monitor_runtime_hooks_mutex}; release = monitor_runtime_hooks.explicit_release; } if (release) release(client_uuid, generation, reason); } void notify_monitor_transport_lost(const std::string_view client_uuid, const std::uint64_t generation) { std::function transport_lost; { std::lock_guard lock {monitor_runtime_hooks_mutex}; transport_lost = monitor_runtime_hooks.transport_lost; } if (transport_lost) transport_lost(client_uuid, generation); } void notify_monitor_unpair(const std::string_view client_uuid) { std::function unpair; { std::lock_guard lock {monitor_runtime_hooks_mutex}; unpair = monitor_runtime_hooks.unpair; } if (unpair) unpair(client_uuid); } void notify_monitor_shutdown() { std::function shutdown; { std::lock_guard lock {monitor_runtime_hooks_mutex}; shutdown = monitor_runtime_hooks.shutdown; } if (shutdown) shutdown(); } bool pending_registry_t::add(pending_t pending, std::string *warning) { expire(std::chrono::steady_clock::now()); if (!pending.launch_id || pending_.contains(pending.launch_id)) return false; if (!pending.encrypted) { const auto count = std::count_if(pending_.begin(), pending_.end(), [&pending](const auto &entry) { return !entry.second.encrypted && entry.second.source_address == pending.source_address; }); if (count != 0) { warning_ = "Plaintext RTSP has more than one pending launch for this source address; the new launch was rejected."; if (warning) *warning = warning_; return false; } } if (pending_.size() >= max_client_vdds * 2) return false; pending_.emplace(pending.launch_id, std::move(pending)); return true; } std::optional pending_registry_t::match_encrypted(const std::string_view client_uuid, const std::string_view crypto_binding, const std::chrono::steady_clock::time_point now) { expire(now); std::optional match; for (const auto &[id, pending] : pending_) { if (pending.encrypted && pending.client_uuid == client_uuid && pending.crypto_binding == crypto_binding) { if (match) return std::nullopt; match = pending; } } return match; } std::optional pending_registry_t::match_plaintext(const std::string_view address, const std::chrono::steady_clock::time_point now) { expire(now); std::optional match; for (const auto &[id, pending] : pending_) { if (!pending.encrypted && pending.source_address == address) { if (match) { warning_ = "Plaintext RTSP source-address routing became ambiguous and was rejected."; return std::nullopt; } match = pending; } } return match; } void pending_registry_t::erase(const std::uint32_t id) { pending_.erase(id); } void pending_registry_t::expire(const std::chrono::steady_clock::time_point now) { for (auto it = pending_.begin(); it != pending_.end();) it = it->second.expires_at <= now ? pending_.erase(it) : std::next(it); } void pending_registry_t::clear() { pending_.clear(); } std::string pending_registry_t::warning() const { return warning_; } bool validate_layout(const std::vector &placements, const std::vector &known_clients, const std::vector &physical_ids, std::string *error) { std::unordered_map parents; std::unordered_set seen; bool primary = false; for (const auto &placement : placements) { if (!contains(known_clients, placement.client_uuid) || !seen.insert(placement.client_uuid).second || placement.client_uuid == placement.anchor_id || placement.gap_px < 0 || placement.gap_px > 100000) { if (error) *error = "Invalid client placement."; return false; } if (placement.anchor_kind == "client" ? !contains(known_clients, placement.anchor_id) : placement.anchor_kind != "physical" || !contains(physical_ids, placement.anchor_id)) { if (error) *error = "Unknown layout anchor."; return false; } if (placement.edge != "left" && placement.edge != "right" && placement.edge != "above" && placement.edge != "below") { if (error) *error = "Invalid layout edge."; return false; } if (placement.alignment != "start" && placement.alignment != "center" && placement.alignment != "end") { if (error) *error = "Invalid layout alignment."; return false; } if (placement.primary && primary) { if (error) *error = "Only one client display may be primary."; return false; } primary = primary || placement.primary; if (placement.anchor_kind == "client") parents.emplace(placement.client_uuid, placement.anchor_id); } for (const auto &[child, parent] : parents) { std::unordered_set path; auto node = child; while (parents.contains(node)) { if (!path.insert(node).second) { if (error) *error = "Client display placement contains a cycle."; return false; } node = parents.at(node); } } return true; } }