diff --git a/docs/configuration.md b/docs/configuration.md index 2d81d8c0dd0..291a1d6950d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -753,6 +753,32 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin +### enable_input_only_mode + + + + + + + + + + + + + + +
Description + When enabled, Sunshine adds a special "Input Only" app to its application list. +
+ Launching it starts a session that forwards input and audio but performs no screen capture: + the video pipeline serves blank frames on request and otherwise idles, minimizing CPU/GPU usage on the host. +
Default@code{} + disabled + @endcode
Example@code{} + enable_input_only_mode = enabled + @endcode
+ ### keybindings diff --git a/src/config.cpp b/src/config.cpp index de28fe97c6f..9c277ae73d0 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -858,6 +858,7 @@ namespace config { true, // always send scancodes true, // high resolution scrolling true, // native pen/touch support + false, // enable input only mode }; /** @@ -1854,6 +1855,7 @@ namespace config { bool_f(vars, "high_resolution_scrolling", input.high_resolution_scrolling); bool_f(vars, "native_pen_touch", input.native_pen_touch); + bool_f(vars, "enable_input_only_mode", input.enable_input_only_mode); 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 ead9e9812db..9e77d683851 100644 --- a/src/config.h +++ b/src/config.h @@ -318,6 +318,8 @@ namespace config { bool high_resolution_scrolling; ///< Enable high-resolution mouse-wheel events. bool native_pen_touch; ///< Enable native pen and touch injection. + + bool enable_input_only_mode; ///< Advertise an "Input Only" app that streams no video capture while keeping input and audio active. }; namespace flag { diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index bf1bf6a6c80..210cb407774 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -493,6 +493,7 @@ namespace nvhttp { std::copy(rikey.cbegin(), rikey.cend(), std::back_inserter(launch_session->gcm_key)); launch_session->host_audio = host_audio; + launch_session->input_only = false; // Overridden by /launch for the Input Only app auto mode = std::stringstream(get_arg(args, "mode", "0x0x0")); // Split mode by the char "x", to populate width/height/fps int x = 0; @@ -1298,6 +1299,13 @@ namespace nvhttp { } auto current_appid = proc::proc.running(); + + // An active placeholder "Input Only" app should not show up as a running game + // to other clients (they would try to resume a session with no real app behind it). + if (proc::proc.is_input_only_running()) { + current_appid = 0; + } + tree.put("root.PairStatus", pair_status); tree.put("root.currentgame", current_appid); tree.put("root.state", current_appid > 0 ? "SUNSHINE_SERVER_BUSY" : "SUNSHINE_SERVER_FREE"); @@ -1400,10 +1408,20 @@ namespace nvhttp { return; } - auto appid = util::from_view(get_arg(args, "appid")); + auto appid_str = get_arg(args, "appid"); + auto appid = util::from_view(appid_str); auto current_appid = proc::proc.running(); - if (current_appid > 0) { + + // Compare numerically so zero-padded or otherwise formatted appids from quirky + // clients still resolve to the placeholder entry. + const std::string input_only_id = proc::get_input_only_app_id(); + const bool is_input_only = config::input.enable_input_only_mode && !input_only_id.empty() && (int) appid == (int) util::from_view(input_only_id); + + // The running placeholder is recognized even if the mode was toggled off after it started. + const bool current_is_input_only = proc::proc.is_input_only_running(); + + if (current_appid > 0 && !is_input_only && !current_is_input_only) { tree.put("root.resume", 0); tree.put("root..status_code", 400); tree.put("root..status_message", "An app is already running on this host"); @@ -1413,15 +1431,22 @@ namespace nvhttp { host_audio = util::from_view(get_arg(args, "localAudioPlayMode")); auto launch_session = make_launch_session(host_audio, args); + launch_session->input_only = is_input_only; - 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; + // A real app launched on top of a running input-only placeholder needs the display + // prepared, even though other sessions are still active. + if (rtsp_stream::session_count() == 0 || (current_is_input_only && !is_input_only)) { + // The display should be restored in case something fails as there are no other sessions + // that depend on it. An input-only session never changes it, so there is nothing to revert. + revert_display_configuration = !is_input_only; // 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 - // change the active displays. - display_device::configure_display(config::video, *launch_session); + // change the active displays. Input-only sessions must not change the + // physical display configuration since they show no video. + if (!is_input_only) { + display_device::configure_display(config::video, *launch_session); + } // Probe encoders again before streaming to ensure our chosen // encoder matches the active GPU (which could have changed @@ -1447,7 +1472,22 @@ namespace nvhttp { return; } - if (appid > 0) { + if (is_input_only) { + BOOST_LOG(info) << "Launching input only session"sv; + + // An input-only session does not conflict with an already running app. Only + // launch the placeholder app when nothing is running yet. + if (current_appid == 0) { + auto err = proc::proc.execute((int) appid, launch_session); + if (err) { + tree.put("root..status_code", err); + tree.put("root..status_message", "Failed to start the specified application"); + tree.put("root.gamesession", 0); + + return; + } + } + } else if (appid > 0) { auto err = proc::proc.execute((int) appid, launch_session); if (err) { tree.put("root..status_code", err); @@ -1499,15 +1539,6 @@ namespace nvhttp { response->close_connection_after_response = true; }); - auto current_appid = proc::proc.running(); - if (current_appid == 0) { - tree.put("root.resume", 0); - tree.put("root..status_code", 503); - tree.put("root..status_message", "No running app to resume"); - - return; - } - auto args = request->parse_query_string(); if ( args.find("rikey"s) == std::end(args) || @@ -1520,20 +1551,42 @@ namespace nvhttp { return; } + // Clean up stopping sessions before taking the app and mode snapshots. The cleanup + // joins them synchronously, and the last input-only session's teardown terminates the + // placeholder. Reading the state afterwards keeps a reconnect from resuming an app + // that no longer exists and silently falling back to desktop capture. + const auto resume_state = resolve_resume_state(); + + if (resume_state.app_id == 0) { + tree.put("root.resume", 0); + tree.put("root..status_code", 503); + tree.put("root..status_message", "No running app to resume"); + + return; + } + // Newer Moonlight clients send localAudioPlayMode on /resume too, // so we should use it if it's present in the args and there are // no active sessions we could be interfering with. - const bool no_active_sessions {rtsp_stream::session_count() == 0}; + const bool no_active_sessions {resume_state.no_active_sessions}; if (no_active_sessions && args.find("localAudioPlayMode"s) != std::end(args)) { host_audio = util::from_view(get_arg(args, "localAudioPlayMode")); } const auto launch_session = make_launch_session(host_audio, args); + // If only the synthetic "Input Only" placeholder is running, a resume must not + // silently upgrade into a full desktop-capture session. The placeholder is recognized + // even if input-only mode was turned off after it started. + launch_session->input_only = resume_state.input_only; + 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 - // change the active displays. - display_device::configure_display(config::video, *launch_session); + // change the active displays. Input-only sessions show no video, so they + // must not change the physical display configuration. + if (!launch_session->input_only) { + display_device::configure_display(config::video, *launch_session); + } // Probe encoders again before streaming to ensure our chosen // encoder matches the active GPU (which could have changed @@ -1629,6 +1682,14 @@ namespace nvhttp { conf_intern.servercert = cert; } + resume_state_t resolve_resume_state() { + // Joining stopping sessions can terminate the last input-only placeholder, so the + // app and mode snapshots must be taken after the cleanup. + const bool no_active_sessions {rtsp_stream::session_count() == 0}; + + return {proc::proc.running(), proc::proc.is_input_only_running(), no_active_sessions}; + } + /** * @brief Check whether a paired client certificate is allowed to connect and retrieve its friendly name. * diff --git a/src/nvhttp.h b/src/nvhttp.h index a7c0fc9ad1a..8d9d9b97b4a 100644 --- a/src/nvhttp.h +++ b/src/nvhttp.h @@ -87,6 +87,26 @@ namespace nvhttp { */ void setup(const std::string &pkey, const std::string &cert); + /** + * @brief Snapshot of the host app state used to handle a resume request. + */ + struct resume_state_t { + int app_id {}; ///< Running app id, or 0 when no app is running. + bool input_only {}; ///< Whether the running app is the input-only placeholder. + bool no_active_sessions {}; ///< Whether no streaming sessions were active after cleanup. + }; + + /** + * @brief Resolve the app state for a resume request. + * + * Stopping sessions are joined first, because the last input-only session's teardown + * can terminate the placeholder. The snapshots must be taken afterwards so a reconnect + * cannot resume an app that no longer exists and fall back to desktop capture. + * + * @return Resolved app id, input-only mode, and active session state. + */ + resume_state_t resolve_resume_state(); + /** * @brief Simple-Web-Server HTTPS backend configured for Sunshine certificate handling. */ diff --git a/src/platform/common.h b/src/platform/common.h index b2616887be3..64fb5731ccf 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -547,6 +547,20 @@ namespace platf { std::optional frame_timestamp; ///< Capture timestamp associated with the frame. + /** + * @brief Report whether `data` points to a CPU-writable image buffer. + * + * Backends that expose an opaque GPU resource through `data` (for example the + * D3D11 VRAM capture path, where `data` is an `ID3D11Texture2D *`) return false + * so CPU-side pixel writes are skipped. Such backends produce blank frames + * through their own conversion path instead. + * + * @return True when pixels can be written directly through `data`. + */ + virtual bool is_cpu_buffer() const { + return true; + } + /** * @brief Destroy the image. */ diff --git a/src/platform/windows/display_vram.cpp b/src/platform/windows/display_vram.cpp index 917c5c390ad..7aa615d01d0 100644 --- a/src/platform/windows/display_vram.cpp +++ b/src/platform/windows/display_vram.cpp @@ -189,6 +189,15 @@ namespace platf::dxgi { // DXGI format of this image texture DXGI_FORMAT format; ///< DXGI format of the captured texture. + /** + * @brief Report that `data` is an opaque D3D11 texture rather than CPU pixels. + * + * @return Always false; blank frames are produced by the conversion path. + */ + bool is_cpu_buffer() const override { + return false; + } + virtual ~img_d3d_t() override { if (encoder_texture_handle) { CloseHandle(encoder_texture_handle); diff --git a/src/process.cpp b/src/process.cpp index 90c79ae3891..8ecc1a97f91 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -33,6 +33,7 @@ #include "logging.h" #include "platform/common.h" #include "process.h" +#include "stream.h" #include "system_tray.h" #include "utility.h" @@ -50,6 +51,17 @@ namespace proc { proc_t proc; ///< Global process registry used to track and terminate child processes. + /// Application ID of the synthetic "Input Only" app entry, or empty when input-only mode is disabled. + std::string input_only_app_id; + + std::mutex input_only_app_id_mutex; + + std::string + get_input_only_app_id() { + std::lock_guard lock(input_only_app_id_mutex); + return input_only_app_id; + } + /** * @brief RAII helper that runs shutdown cleanup when destroyed. */ @@ -368,8 +380,21 @@ namespace proc { return 0; } + bool proc_t::is_input_only_running() const { + return (_app_id > 0 || placebo) && _app.input_only; + } + void proc_t::terminate() { - input::terminate_gamepads(); + // Gamepads of an input-only client must survive the placeholder being replaced by a real + // app, but there is nothing left to keep once every session is gone. + // + // This must not use rtsp_stream::session_count(): it joins stopping sessions, which + // deadlocks when terminate() is reached from a session teardown thread. + const bool input_only = _app.input_only; + if (!input_only || stream::session::running_session_count() == 0) { + input::terminate_gamepads(); + } + std::error_code ec; placebo = false; terminate_process_group(_process, _process_group, _app.exit_timeout); @@ -408,7 +433,9 @@ namespace proc { // Only show the Stopped notification if we actually have an app to stop // Since terminate() is always run when a new app has started - if (proc::proc.get_last_run_app_name().length() > 0 && has_run) { + // The input-only placeholder is not a real app: it never configured the display, so + // reverting here would clobber the configuration applied for an app launched over it. + if (proc::proc.get_last_run_app_name().length() > 0 && has_run && !input_only) { #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 system_tray::update_tray_stopped(proc::proc.get_last_run_app_name()); #endif @@ -417,6 +444,11 @@ namespace proc { } _app_id = -1; + _app = {}; + + // The cleared app owns the container these iterators pointed into + _app_prep_it = {}; + _app_prep_begin = {}; } const std::vector &proc_t::get_apps() const { @@ -848,6 +880,40 @@ namespace proc { apps.emplace_back(std::move(ctx)); } + // Synthetic "Input Only" entry. Launching it starts a session where video capture is + // skipped (a single blank frame is sent to keep the stream alive) while input and + // audio keep working normally. Added last so enabling the mode never changes the + // generated app IDs of existing apps. + if (config::input.enable_input_only_mode) { + proc::ctx_t ctx; + ctx.name = "Input Only"; + ctx.elevated = false; + ctx.auto_detach = true; + ctx.wait_all = true; + ctx.exit_timeout = std::chrono::seconds {5}; + ctx.input_only = true; + + auto possible_ids = calculate_app_id(ctx.name, ctx.image_path, i++); + if (ids.count(std::get<0>(possible_ids)) == 0) { + // Avoid using index to generate id if possible + ctx.id = std::get<0>(possible_ids); + } else { + // Fallback to include index on collision + ctx.id = std::get<1>(possible_ids); + } + ids.insert(ctx.id); + + { + std::lock_guard lock(input_only_app_id_mutex); + input_only_app_id = ctx.id; + } + + apps.emplace_back(std::move(ctx)); + } else { + std::lock_guard lock(input_only_app_id_mutex); + input_only_app_id.clear(); + } + return proc::proc_t { this_env, std::move(apps) diff --git a/src/process.h b/src/process.h index 774b0bacd26..e3339da14e6 100644 --- a/src/process.h +++ b/src/process.h @@ -13,7 +13,9 @@ #endif // standard includes +#include #include +#include #include // local includes @@ -79,6 +81,7 @@ namespace proc { bool auto_detach; ///< Whether the process should detach automatically. bool wait_all; ///< Whether Sunshine waits for all child processes. std::chrono::seconds exit_timeout; ///< Exit timeout. + bool input_only = false; ///< Synthetic app that streams no captured video while keeping input and audio active. }; /** @@ -117,6 +120,16 @@ namespace proc { */ int running(); + /** + * @brief Check whether the running app is the synthetic "Input Only" placeholder. + * + * Unlike the configured app ID, this stays valid for the lifetime of the session even if + * `input.enable_input_only_mode` is turned off while the placeholder is still running. + * + * @return true if an input-only session is currently running. + */ + bool is_input_only_running() const; + ~proc_t(); /** @@ -243,4 +256,19 @@ namespace proc { void terminate_process_group(boost::process::v1::child &child_process, boost::process::v1::group &group, std::chrono::seconds exit_timeout); extern proc_t proc; + + /// Guards input_only_app_id, which is written on config refresh and read from request handlers. + extern std::mutex input_only_app_id_mutex; + + /// Application ID of the synthetic "Input Only" app entry, or empty when input-only mode is disabled. + /// Must be accessed while holding input_only_app_id_mutex, or via get_input_only_app_id(). + extern std::string input_only_app_id; + + /** + * @brief Get a consistent copy of the synthetic "Input Only" application ID. + * + * @return The ID string, or an empty string when input-only mode is disabled. + */ + std::string + get_input_only_app_id(); } // namespace proc diff --git a/src/rtsp.cpp b/src/rtsp.cpp index 4532c2b09c5..b810cde7695 100644 --- a/src/rtsp.cpp +++ b/src/rtsp.cpp @@ -1154,6 +1154,7 @@ namespace rtsp_stream { config.monitor.dynamicRange = (int) util::from_view(args.at("x-nv-video[0].dynamicRangeMode"sv)); config.monitor.chromaSamplingType = (int) util::from_view(args.at("x-ss-video[0].chromaSamplingType"sv)); config.monitor.enableIntraRefresh = (int) util::from_view(args.at("x-ss-video[0].intraRefresh"sv)); + config.monitor.input_only = session.input_only; configuredBitrateKbps = util::from_view(args.at("x-ml-video.configuredBitrateKbps"sv)); } catch (std::out_of_range &) { diff --git a/src/rtsp.h b/src/rtsp.h index 108c881e200..f22fbbe2adf 100644 --- a/src/rtsp.h +++ b/src/rtsp.h @@ -82,6 +82,7 @@ namespace rtsp_stream { uint32_t control_connect_data; ///< Client-provided token used when connecting the control channel. bool host_audio; ///< Whether host audio should be played locally. + bool input_only; ///< Whether this is an input-only session with no video capture. std::string unique_id; ///< Moonlight client unique identifier for this launch request. int width; ///< Frame or display width in pixels. int height; ///< Frame or display height in pixels. diff --git a/src/stream.cpp b/src/stream.cpp index a222b4045e2..a797f951616 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -2274,6 +2274,13 @@ namespace stream { return session.client_cert; } + /** + * @brief Return the number of streaming sessions that have not finished teardown. + */ + std::uint32_t running_session_count() { + return running_sessions; + } + /** * @brief Stop the active streaming session and prevent new packets from being queued. */ @@ -2318,11 +2325,23 @@ namespace stream { // If this is the last session, invoke the platform callbacks if (--running_sessions == 0) { + // If the only thing running was the synthetic "Input Only" placeholder, there is + // no real app to keep alive once the last client disconnects. Terminate it so the + // host does not stay stuck reporting a phantom running app. + const bool was_input_only = proc::proc.is_input_only_running(); + if (was_input_only) { + proc::proc.terminate(); + } + bool revert_display_config {config::video.dd.config_revert_on_disconnect}; if (proc::proc.running()) { #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 system_tray::update_tray_pausing(proc::proc.get_last_run_app_name()); #endif + } else if (was_input_only) { + // The input-only placeholder never configured the display, so there is nothing to + // revert on its account. There are no sessions left, so gamepads can go away too. + input::terminate_gamepads(); } else { // We have no app running and also no clients anymore. revert_display_config = true; @@ -2377,7 +2396,10 @@ namespace stream { if (++running_sessions == 1) { platf::streaming_will_start(); #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 - system_tray::update_tray_playing(proc::proc.get_last_run_app_name()); + // The "Input Only" placeholder is not a real app, so don't announce it as playing. + if (!proc::proc.is_input_only_running()) { + system_tray::update_tray_playing(proc::proc.get_last_run_app_name()); + } #endif } diff --git a/src/stream.h b/src/stream.h index c0b0f962378..9e3a7a56ddb 100644 --- a/src/stream.h +++ b/src/stream.h @@ -5,6 +5,7 @@ #pragma once // standard includes +#include #include // lib includes @@ -94,5 +95,14 @@ namespace stream { * @return PEM certificate associated with the session's client. */ const std::string &client_cert(session_t &session); + /** + * @brief Return the number of streaming sessions that have not finished teardown. + * + * This is a non-blocking snapshot that is safe to call from a session teardown + * thread, unlike rtsp_stream::session_count() which joins stopping sessions. + * + * @return Number of live streaming sessions. + */ + std::uint32_t running_session_count(); } // namespace session } // namespace stream diff --git a/src/video.cpp b/src/video.cpp index 995e8a73a1e..09c47091ff8 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -708,6 +708,8 @@ namespace video { struct sync_session_t { sync_session_ctx_t *ctx; ///< Shared capture/encode synchronization context. std::unique_ptr session; ///< Active encoder session used by the capture thread. + + bool encoded_initial_frame {}; ///< Whether the current encoder session already emitted an initial IDR frame. }; /** @@ -1753,7 +1755,7 @@ namespace video { continue; } - if (frame_captured) { + if (frame_captured && !capture_ctx->config.input_only) { capture_ctx->images->raise(img); } @@ -1773,78 +1775,168 @@ namespace video { return false; } + // If every remaining session is input-only, unwind the capture backend so the + // outer loop can switch to its idle path instead of grabbing frames forever. + if (!capture_ctxs.empty() && !std::any_of(std::begin(capture_ctxs), std::end(capture_ctxs), [](const capture_ctx_t &capture_ctx) { + return capture_ctx.images->running() && !capture_ctx.config.input_only; + })) { + artificial_reinit = true; + return false; + } + return true; }; - auto status = disp->capture(push_captured_image_callback, pull_free_image_callback, &display_cursor); + // Shared display reinitialization sequence. Returns false when the capture thread must exit. + auto do_reinit = [&]() -> bool { + reinit_event.raise(true); - if (artificial_reinit && status != platf::capture_e::capture_error) { - status = platf::capture_e::reinit; - - artificial_reinit = false; - } + // Some classes of images contain references to the display --> display won't delete unless img is deleted + for (auto &img : imgs) { + img.reset(); + } - switch (status) { - case platf::capture_e::reinit: - { - reinit_event.raise(true); + // Sessions can stop at any time, including while the display is being reinitialized. + auto prune_stopped_sessions = [&capture_ctxs]() { + KITTY_WHILE_LOOP(auto capture_ctx = std::begin(capture_ctxs), capture_ctx != std::end(capture_ctxs), { + if (!capture_ctx->images->running()) { + capture_ctx = capture_ctxs.erase(capture_ctx); + continue; + } - // Some classes of images contain references to the display --> display won't delete unless img is deleted - for (auto &img : imgs) { - img.reset(); + ++capture_ctx; + }); + }; + + // Prune up front: if the display refcount is already 1, the wait loop below never runs + // and stopped sessions would be left behind, including at the front of capture_ctxs + // whose config is used to create the new display. + prune_stopped_sessions(); + + // display_wp is modified in this thread only + // Wait for the other shared_ptr's of display to be destroyed. + // New displays will only be created in this thread. + while (display_wp->use_count() != 1) { + // Free images that weren't consumed by the encoders. These can reference the display and prevent + // the ref count from reaching 1. We do this here rather than on the encoder thread to avoid race + // conditions where the encoding loop might free a good frame after reinitializing if we capture + // a new frame here before the encoder has finished reinitializing. + KITTY_WHILE_LOOP(auto capture_ctx = std::begin(capture_ctxs), capture_ctx != std::end(capture_ctxs), { + if (!capture_ctx->images->running()) { + capture_ctx = capture_ctxs.erase(capture_ctx); + continue; } - // display_wp is modified in this thread only - // Wait for the other shared_ptr's of display to be destroyed. - // New displays will only be created in this thread. - while (display_wp->use_count() != 1) { - // Free images that weren't consumed by the encoders. These can reference the display and prevent - // the ref count from reaching 1. We do this here rather than on the encoder thread to avoid race - // conditions where the encoding loop might free a good frame after reinitializing if we capture - // a new frame here before the encoder has finished reinitializing. - KITTY_WHILE_LOOP(auto capture_ctx = std::begin(capture_ctxs), capture_ctx != std::end(capture_ctxs), { - if (!capture_ctx->images->running()) { - capture_ctx = capture_ctxs.erase(capture_ctx); - continue; - } - - while (capture_ctx->images->try_pop()) { - } - - ++capture_ctx; - }); - - std::this_thread::sleep_for(20ms); + while (capture_ctx->images->try_pop()) { } - while (capture_ctx_queue->running()) { - // Release the display before reenumerating displays, since some capture backends - // only support a single display session per device/application. - disp.reset(); + ++capture_ctx; + }); - // Refresh display names since a display removal might have caused the reinitialization - refresh_displays(encoder.platform_formats->dev_type, display_names, display_p); + std::this_thread::sleep_for(20ms); + } - // Process any pending display switch with the new list of displays - if (switch_display_event->peek()) { - display_p = std::clamp(*switch_display_event->pop(), 0, static_cast(display_names.size()) - 1); - } + // Sessions may have stopped while we were waiting for the display to be released. + prune_stopped_sessions(); - // reset_display() will sleep between retries - reset_display(disp, encoder.platform_formats->dev_type, display_names[display_p], capture_ctxs.front().config); - if (disp) { - break; - } - } - if (!disp) { - return; - } + // Pick up sessions that joined while the display was being reinitialized. + while (capture_ctx_queue->peek()) { + capture_ctxs.emplace_back(std::move(*capture_ctx_queue->pop())); + } - display_wp = disp; + if (capture_ctxs.empty()) { + // Every session went away while we were reinitializing. Wait for a replacement so a + // session joining now isn't left without a capture thread, and exit if the shared + // capture thread is being torn down instead. + if (auto next_ctx = capture_ctx_queue->pop()) { + capture_ctxs.emplace_back(std::move(*next_ctx)); + } else { + return false; + } + } + + while (capture_ctx_queue->running()) { + // Release the display before reenumerating displays, since some capture backends + // only support a single display session per device/application. + disp.reset(); + + // Refresh display names since a display removal might have caused the reinitialization + refresh_displays(encoder.platform_formats->dev_type, display_names, display_p); + + // Process any pending display switch with the new list of displays + if (switch_display_event->peek()) { + display_p = std::clamp(*switch_display_event->pop(), 0, static_cast(display_names.size()) - 1); + } + + // reset_display() will sleep between retries + reset_display(disp, encoder.platform_formats->dev_type, display_names[display_p], capture_ctxs.front().config); + if (disp) { + break; + } + } + if (!disp) { + return false; + } + + display_wp = disp; + + reinit_event.reset(); + return true; + }; + + // Only run the capture backend when at least one active session needs video frames. + // Input-only sessions serve blank frames and never consume captured images. + bool capture_needed = std::any_of(std::begin(capture_ctxs), std::end(capture_ctxs), [](const capture_ctx_t &capture_ctx) { + return capture_ctx.images->running() && !capture_ctx.config.input_only; + }); + + if (!capture_needed) { + std::this_thread::sleep_for(50ms); + + // Prune sessions that have stopped while we were idling + KITTY_WHILE_LOOP(auto capture_ctx = std::begin(capture_ctxs), capture_ctx != std::end(capture_ctxs), { + if (!capture_ctx->images->running()) { + capture_ctx = capture_ctxs.erase(capture_ctx); - reinit_event.reset(); continue; } + + ++capture_ctx; + }) + + while (capture_ctx_queue->peek()) { + capture_ctxs.emplace_back(std::move(*capture_ctx_queue->pop())); + } + + if (switch_display_event->peek()) { + if (!do_reinit()) { + return; + } + + continue; + } + + // Keep idling or pick up newly queued sessions by re-evaluating capture_needed at the top + // of the loop. Note: we must not fall through to the status switch with a non-terminal + // status because capture_e::ok is handled as end-of-capture and would kill this shared thread. + continue; + } + + auto status = disp->capture(push_captured_image_callback, pull_free_image_callback, &display_cursor); + + if (artificial_reinit && status != platf::capture_e::capture_error) { + status = platf::capture_e::reinit; + + artificial_reinit = false; + } + + switch (status) { + case platf::capture_e::reinit: + if (!do_reinit()) { + return; + } + + continue; case platf::capture_e::capture_error: case platf::capture_e::ok: case platf::capture_e::timeout: @@ -2502,6 +2594,48 @@ namespace video { return nullptr; } + void fill_blank_image(platf::img_t &img) { + // GPU-backed images expose an opaque resource through `data` (on Windows VRAM it + // is an ID3D11Texture2D pointer), so writing pixels here would corrupt it. Those + // backends convert their blank/dummy images to black through the encoder path. + if (!img.is_cpu_buffer()) { + return; + } + + if (img.data == nullptr || img.width <= 0 || img.height <= 0 || img.row_pitch <= 0) { + return; + } + + // Backends that don't fill in pixel_pitch can still be identified by the row pitch. + const auto pixel_pitch = img.pixel_pitch > 0 ? img.pixel_pitch : (img.row_pitch / img.width); + + // A row that is shorter than the pixels it is supposed to hold is malformed + if (static_cast(img.row_pitch) < static_cast(img.width) * pixel_pitch) { + return; + } + + if (pixel_pitch == 4) { + for (auto y = 0; y < img.height; ++y) { + auto row = img.data + static_cast(y) * img.row_pitch; + std::fill_n(row, static_cast(img.width) * 4, (std::uint8_t) 0x00); + + // Restore the alpha channel, which is the last byte of every BGRA/xRGB pixel + for (auto x = 0; x < img.width; ++x) { + row[x * 4 + 3] = 0xFF; + } + } + } else if (pixel_pitch == 1) { + for (auto y = 0; y < img.height; ++y) { + std::fill_n(img.data + static_cast(y) * img.row_pitch, img.width, (std::uint8_t) 0x00); + } + + auto uv_plane = img.data + static_cast(img.row_pitch) * img.height; + for (auto y = 0; y < img.height / 2; ++y) { + std::fill_n(uv_plane + static_cast(y) * img.row_pitch, img.width, (std::uint8_t) 0x80); + } + } + } + /** * @brief Run one encode loop for a display capture stream. * @@ -2560,13 +2694,74 @@ namespace video { { // Load a dummy image into the AVFrame to ensure we have something to encode - // even if we timeout waiting on the first frame. This is a relatively large + // even if we timeout waiting for the first frame. This is a relatively large // allocation which can be freed immediately after convert(), so we do this // in a separate scope. auto dummy_img = disp->alloc_img(); - if (!dummy_img || disp->dummy_img(dummy_img.get()) || session->convert(*dummy_img)) { + if (!dummy_img || disp->dummy_img(dummy_img.get())) { return; } + + // Input-only sessions never capture, so this dummy frame is the only video + // the client will ever receive. Force it to opaque black: some backends + // leave the buffer uninitialized (or zero the alpha channel), which decodes + // as a green screen on some clients. + if (config.input_only) { + fill_blank_image(*dummy_img); + } + + if (session->convert(*dummy_img)) { + return; + } + } + + if (config.input_only) { + BOOST_LOG(info) << "Input only session: idling without video capture; serving blank IDR frames on request"sv; + + // The display (and with it this encoder session) can be recreated while the stream + // stays alive, so remember whether the *current* encoder has produced a frame. The + // frame counter is shared across reinits and can't be used for that purpose. + bool encoded_initial_frame = false; + + // The client's first-frame checks need at least one complete frame, and the + // client may request additional IDR frames if that frame was missed (for + // example, if it arrived before the client's video socket was bound) or if + // the client asks for a refresh. Keep serving the blank frame until the + // session ends instead of blocking forever on the first one. + while (!shutdown_event->peek() && images->running()) { + // There are no reference frames to recover from in a blank stream, so a request to + // invalidate reference frames is served by emitting a fresh IDR frame instead. + bool rfi_requested = false; + while (invalidate_ref_frames_events->pop(0ms)) { + rfi_requested = true; + } + + if (reinit_event.peek() && encoded_initial_frame) { + // The display is being reinitialized. Return so capture_async can recreate + // the encode session, releasing our reference to the old display in the process. + break; + } + + bool idr_requested = idr_events->peek(); + if (!encoded_initial_frame || idr_requested || rfi_requested) { + if (idr_requested) { + idr_events->pop(); + } + + session->request_idr_frame(); + if (encode(frame_nr++, *session, packets, channel_data, {})) { + BOOST_LOG(error) << "Could not encode dummy video packet"sv; + return; + } + + session->request_normal_frame(); + encoded_initial_frame = true; + continue; + } + + std::this_thread::sleep_for(100ms); + } + return; } while (true) { @@ -2739,7 +2934,8 @@ namespace video { * * @param disp Display connection or display handle. * @param encoder Encoder configuration or encoder instance. - * @param img Image or frame object to read from or populate. + * @param img Frame used to seed the encoder. Input-only sessions ignore it and seed + * themselves with a blank frame instead. * @param ctx Native context object used by the operation or callback. * @return Constructed synced session object. */ @@ -2773,7 +2969,22 @@ namespace video { } // Load the initial image to prepare for encoding - if (session->convert(img)) { + if (ctx.config.input_only) { + // Input-only sessions must never be seeded with captured content: the shared image can + // hold a live desktop frame (or be uninitialized), so convert a dedicated blank frame. + auto dummy_img = disp->alloc_img(); + if (!dummy_img || disp->dummy_img(dummy_img.get())) { + BOOST_LOG(error) << "Could not get dummy image for input only session"sv; + return std::nullopt; + } + + fill_blank_image(*dummy_img); + + if (session->convert(*dummy_img)) { + BOOST_LOG(error) << "Could not convert initial image"sv; + return std::nullopt; + } + } else if (session->convert(img)) { BOOST_LOG(error) << "Could not convert initial image"sv; return std::nullopt; } @@ -2848,41 +3059,134 @@ namespace video { synced_sessions.emplace_back(std::move(*synced_session)); } + // Status returned to the caller when the capture backend unwinds. auto ec = platf::capture_e::ok; + + // True while at least one active session needs captured video frames. Input-only sessions + // are served blank frames and must not keep the capture backend running. + auto capture_needed = [&synced_sessions]() { + return std::any_of(std::begin(synced_sessions), std::end(synced_sessions), [](const sync_session_t &synced_session) { + return !synced_session.ctx->config.input_only; + }); + }; + + // Creates encoder sessions for clients that joined since the last pass. + // Returns false when the queue was stopped or a session could not be created. + auto accept_pending_sessions = [&](platf::img_t &seed_img) -> bool { + while (encode_session_ctx_queue.peek()) { + auto encode_session_ctx = encode_session_ctx_queue.pop(); + if (!encode_session_ctx) { + return false; + } + + synced_session_ctxs.emplace_back(std::make_unique(std::move(*encode_session_ctx))); + + auto encode_session = make_synced_session(disp.get(), encoder, seed_img, *synced_session_ctxs.back()); + if (!encode_session) { + ec = platf::capture_e::capture_error; + return false; + } + + synced_sessions.emplace_back(std::move(*encode_session)); + } + + return true; + }; + + // Drops sessions whose stream has shut down. Returns false when the last session went away. + auto prune_shutdown_sessions = [&]() -> bool { + KITTY_WHILE_LOOP(auto pos = std::begin(synced_sessions), pos != std::end(synced_sessions), { + auto ctx = pos->ctx; + if (!ctx->shutdown_event->peek()) { + ++pos; + continue; + } + + // Let waiting thread know it can delete shutdown_event + ctx->join_event->raise(true); + + pos = synced_sessions.erase(pos); + synced_session_ctxs.erase(std::find_if(std::begin(synced_session_ctxs), std::end(synced_session_ctxs), [&ctx_p = ctx](auto &ctx) { + return ctx.get() == ctx_p; + })); + }) + + return !synced_sessions.empty(); + }; + + // Emits the initial frame, or a requested refresh, for every input-only session. The frame + // content is the blank image converted when the session was created, so no captured video + // can leak to an input-only client. + auto serve_input_only_sessions = [&]() { + for (auto &synced_session : synced_sessions) { + auto ctx = synced_session.ctx; + if (!ctx->config.input_only) { + continue; + } + + bool idr_requested = ctx->idr_events->peek(); + if (idr_requested) { + ctx->idr_events->pop(); + } + + if (!idr_requested && synced_session.encoded_initial_frame) { + continue; + } + + synced_session.session->request_idr_frame(); + if (encode(ctx->frame_nr++, *synced_session.session, ctx->packets, ctx->channel_data, {})) { + BOOST_LOG(error) << "Could not encode dummy video packet"sv; + ctx->shutdown_event->raise(true); + + continue; + } + + // Track it on the synced session, not on the context: the context survives display + // reinitialization, while a recreated encoder session needs a fresh initial IDR frame. + synced_session.encoded_initial_frame = true; + synced_session.session->request_normal_frame(); + } + }; + while (encode_session_ctx_queue.running()) { - auto push_captured_image_callback = [&](std::shared_ptr &&captured_image, bool frame_captured) { - while (encode_session_ctx_queue.peek()) { - auto encode_session_ctx = encode_session_ctx_queue.pop(); - if (!encode_session_ctx) { - return false; - } + // Nothing to capture: keep input-only clients fed with blank frames instead of running + // the capture backend at full framerate. + if (!capture_needed()) { + if (!accept_pending_sessions(*img) || !prune_shutdown_sessions()) { + return ec != platf::capture_e::ok ? ec : encode_e::ok; + } - synced_session_ctxs.emplace_back(std::make_unique(std::move(*encode_session_ctx))); + serve_input_only_sessions(); - auto encode_session = make_synced_session(disp.get(), encoder, *captured_image, *synced_session_ctxs.back()); - if (!encode_session) { - ec = platf::capture_e::capture_error; - return false; - } + // A failed blank-frame encode raises shutdown_event; prune right away so the + // session's join_event is released instead of after the next idle interval. + if (!prune_shutdown_sessions()) { + return ec != platf::capture_e::ok ? ec : encode_e::ok; + } - synced_sessions.emplace_back(std::move(*encode_session)); + if (switch_display_event->peek()) { + return encode_e::reinit; } - KITTY_WHILE_LOOP(auto pos = std::begin(synced_sessions), pos != std::end(synced_sessions), { - auto ctx = pos->ctx; - if (ctx->shutdown_event->peek()) { - // Let waiting thread know it can delete shutdown_event - ctx->join_event->raise(true); + std::this_thread::sleep_for(50ms); + continue; + } - pos = synced_sessions.erase(pos); - synced_session_ctxs.erase(std::find_if(std::begin(synced_session_ctxs), std::end(synced_session_ctxs), [&ctx_p = ctx](auto &ctx) { - return ctx.get() == ctx_p; - })); + // Set when the capture backend was unwound because no session needs video anymore. + bool unwind_for_idle = false; - if (synced_sessions.empty()) { - return false; - } + auto push_captured_image_callback = [&](std::shared_ptr &&captured_image, bool frame_captured) { + auto &seed_img = captured_image ? *captured_image : *img; + if (!accept_pending_sessions(seed_img) || !prune_shutdown_sessions()) { + return false; + } + serve_input_only_sessions(); + + KITTY_WHILE_LOOP(auto pos = std::begin(synced_sessions), pos != std::end(synced_sessions), { + auto ctx = pos->ctx; + if (ctx->config.input_only) { + ++pos; continue; } @@ -2895,6 +3199,7 @@ namespace video { BOOST_LOG(error) << "Could not convert image"sv; ctx->shutdown_event->raise(true); + ++pos; continue; } @@ -2907,6 +3212,7 @@ namespace video { BOOST_LOG(error) << "Could not encode video packet"sv; ctx->shutdown_event->raise(true); + ++pos; continue; } @@ -2915,11 +3221,22 @@ namespace video { ++pos; }) + // Sessions whose convert() or encode() failed above are dropped here so their + // join_event is released without waiting for the next captured frame. + if (!prune_shutdown_sessions()) { + return false; + } + if (switch_display_event->peek()) { ec = platf::capture_e::reinit; return false; } + if (!capture_needed()) { + unwind_for_idle = true; + return false; + } + return true; }; @@ -2930,6 +3247,13 @@ namespace video { }; auto status = disp->capture(push_captured_image_callback, pull_free_image_callback, &display_cursor); + + if (unwind_for_idle) { + // Only input-only sessions are left. Reinitialize so capture backends that tear down + // their state when capture() returns are in a clean state before we start idling. + return encode_e::reinit; + } + switch (status) { case platf::capture_e::reinit: case platf::capture_e::capture_error: diff --git a/src/video.h b/src/video.h index 8b2158b6137..b2752c705ee 100644 --- a/src/video.h +++ b/src/video.h @@ -51,6 +51,8 @@ namespace video { int dynamicRange; ///< Encoding color depth: 0 = 8-bit, 1 = 10-bit. int chromaSamplingType; ///< Chroma sampling type: 0 = 4:2:0, 1 = 4:4:4. int enableIntraRefresh; ///< Intra refresh setting: 0 = disabled, 1 = enabled. + + bool input_only; ///< Input-only session: encode one blank IDR frame, then idle without capturing video. }; /** @@ -244,6 +246,26 @@ namespace video { */ std::optional match_frame_timestamp(frame_timestamp_queue_t ×tamps, int64_t pts); + /** + * @brief Fill an image buffer with opaque black pixels. + * + * Capture backends may hand out uninitialized buffers, or buffers with a zeroed alpha + * channel, for `dummy_img()`. Such a frame decodes as a green screen on some clients, so + * anything that is meant to be shown as a blank placeholder must be normalized first. + * + * Packed BGRA/xRGB images (`pixel_pitch == 4`) and planar NV12 images (`pixel_pitch == 1`, + * where the half-height interleaved UV plane directly follows the Y plane) are supported. + * Any other layout, or a descriptor that is unusable (no buffer, degenerate dimensions, or a + * row pitch too small to hold a row of pixels), is left untouched. + * + * Images whose `data` is not a CPU-writable buffer (`img_t::is_cpu_buffer()` returns false) + * are skipped, because writing through an opaque GPU resource would corrupt it. Those + * backends render blank frames as black in their own conversion path. + * + * @param img Image to fill in place. + */ + void fill_blank_image(platf::img_t &img); + /** * @brief Pixel formats supported by one encoder backend. */ diff --git a/src_assets/common/assets/web/configs/config_tabs.json b/src_assets/common/assets/web/configs/config_tabs.json index b1940b33d82..a7209dd385f 100644 --- a/src_assets/common/assets/web/configs/config_tabs.json +++ b/src_assets/common/assets/web/configs/config_tabs.json @@ -31,6 +31,7 @@ "mouse": "enabled", "high_resolution_scrolling": "enabled", "native_pen_touch": "enabled", + "enable_input_only_mode": "disabled", "keybindings": "[0x10,0xA0,0x11,0xA2,0x12,0xA4]" } }, diff --git a/src_assets/common/assets/web/configs/tabs/Inputs.vue b/src_assets/common/assets/web/configs/tabs/Inputs.vue index 0bf664555ff..a03248c8a2f 100644 --- a/src_assets/common/assets/web/configs/tabs/Inputs.vue +++ b/src_assets/common/assets/web/configs/tabs/Inputs.vue @@ -348,6 +348,14 @@ watch( v-model="config.native_pen_touch" default="true" > + + + diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 23f5baa943c..edb5a9d76d4 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -245,6 +245,8 @@ "dscp_tagging_desc": "Mark audio and video packets with DSCP priority tags when requested by the client. Disabling this may help if audio crackles or stutters on Wi-Fi, as some routers handle tagged traffic poorly.", "virtualhid_randomize_mac": "Randomize virtual controller MAC", "virtualhid_randomize_mac_desc": "Use a random MAC address for PlayStation-style virtual controllers instead of one based on the controller index. This avoids mixing per-controller settings when controllers are swapped on the client.", + "enable_input_only_mode": "Enable Input Only Mode", + "enable_input_only_mode_desc": "Adds a special \"Input Only\" app that streams input and audio without screen capture. The video pipeline serves blank frames on request and otherwise idles, minimizing CPU/GPU usage on the host.", "encoder": "Force a Specific Encoder", "encoder_desc": "Force a specific encoder, otherwise Sunshine will select the best available option. Note: If you specify a hardware encoder on Windows, it must match the GPU where the display is connected.", "encoder_software": "Software", diff --git a/tests/unit/test_config.cpp b/tests/unit/test_config.cpp index 5cff436cf65..f578dcadfb2 100644 --- a/tests/unit/test_config.cpp +++ b/tests/unit/test_config.cpp @@ -235,6 +235,28 @@ TEST_F(ConfigPersistenceTest, ParsesDscpTaggingOption) { EXPECT_TRUE(config::stream.dscp_tagging); } +/** + * @brief Verify the input-only mode option parses enabled and disabled values. + */ +TEST_F(ConfigPersistenceTest, ParsesEnableInputOnlyModeOption) { + const auto original_enable_input_only_mode = config::input.enable_input_only_mode; + const auto restore_input_only_mode = util::fail_guard([original_enable_input_only_mode]() { + config::input.enable_input_only_mode = original_enable_input_only_mode; + }); + + const auto apps_file = config_file().parent_path() / "sunshine_test_config_apps.json"; + ASSERT_EQ(file_handler::write_file(apps_file.string().c_str(), "{}"), 0); + config::stream.file_apps = apps_file.string(); + + EXPECT_FALSE(config::input.enable_input_only_mode); + + config::apply_config_for_test("enable_input_only_mode = enabled\n"); + EXPECT_TRUE(config::input.enable_input_only_mode); + + config::apply_config_for_test("enable_input_only_mode = disabled\n"); + EXPECT_FALSE(config::input.enable_input_only_mode); +} + /** * @brief Verify mixed-case boolean values update configuration correctly. */ diff --git a/tests/unit/test_process.cpp b/tests/unit/test_process.cpp index 68269cee9e5..5ad58c24b5c 100644 --- a/tests/unit/test_process.cpp +++ b/tests/unit/test_process.cpp @@ -27,6 +27,8 @@ #endif // local includes +#include +#include #include #include @@ -842,3 +844,217 @@ TEST_F(ProcessRefreshTest, FileTimeType_DefaultValueComparison) { // if that test passes, the optional fix works regardless of platform behavior. SUCCEED(); } + +/** + * @brief Test fixture for the synthetic "Input Only" app entry. + */ +class ProcessInputOnlyTest: public ProcessRefreshTest { // NOSONAR(cpp:S3656): protected members are intentional for test fixture subclassing +protected: + bool saved_enable_input_only_mode; ///< Input-only mode setting restored after each test. + std::string saved_input_only_app_id; ///< Cached placeholder app id restored after each test. + + void SetUp() override { + ProcessRefreshTest::SetUp(); + saved_enable_input_only_mode = config::input.enable_input_only_mode; + saved_input_only_app_id = proc::input_only_app_id; + } + + void TearDown() override { + config::input.enable_input_only_mode = saved_enable_input_only_mode; + proc::input_only_app_id = saved_input_only_app_id; + ProcessRefreshTest::TearDown(); + } + + /** + * @brief Build a context matching the synthetic "Input Only" placeholder. + * + * @param id Identifier of the placeholder entry. + * @return Context that launches no command and streams no captured video. + */ + static proc::ctx_t make_placeholder_ctx(const std::string &id) { + proc::ctx_t ctx; + ctx.name = "Input Only"; + ctx.id = id; + ctx.auto_detach = true; + ctx.wait_all = true; + ctx.exit_timeout = std::chrono::seconds {5}; + ctx.input_only = true; + return ctx; + } + + /** + * @brief Build a context matching an ordinary application. + * + * @param id Identifier of the application entry. + * @return Context that launches no command and captures video normally. + */ + static proc::ctx_t make_regular_ctx(const std::string &id) { + proc::ctx_t ctx; + ctx.name = "Desktop"; + ctx.id = id; + return ctx; + } + + /** + * @brief Create a process manager owning only the supplied application contexts. + * + * @param apps Application contexts owned by the manager. + * @return Process manager using the current environment and the supplied apps. + */ + static proc::proc_t make_process(std::vector apps) { + boost::process::v1::environment env = boost::this_process::environment(); + return proc::proc_t {std::move(env), std::move(apps)}; + } +}; + +TEST_F(ProcessInputOnlyTest, Parse_AddsInputOnlyEntryWhenEnabled) { + config::input.enable_input_only_mode = true; + proc::input_only_app_id.clear(); + + const auto path = test_dir / "apps_input_only_enabled.json"; + writeAppsJson(path, {"Game One", "Game Two"}); + + auto parsed = proc::parse(path.string()); + ASSERT_TRUE(parsed.has_value()); + + const auto &apps = parsed->get_apps(); + ASSERT_EQ(apps.size(), 3U); + + const auto &entry = apps.back(); + EXPECT_EQ(entry.name, "Input Only"); + EXPECT_TRUE(entry.cmd.empty()); + EXPECT_FALSE(entry.id.empty()); + EXPECT_TRUE(entry.input_only); + EXPECT_EQ(proc::input_only_app_id, entry.id); + + // Existing apps keep stable IDs regardless of the input-only flag + EXPECT_EQ(apps[0].name, "Game One"); + EXPECT_FALSE(apps[0].input_only); + EXPECT_EQ(apps[1].name, "Game Two"); + EXPECT_FALSE(apps[1].input_only); +} + +TEST_F(ProcessInputOnlyTest, Parse_OmitsInputOnlyEntryWhenDisabled) { + config::input.enable_input_only_mode = false; + proc::input_only_app_id = "stale-id"; + + const auto path = test_dir / "apps_input_only_disabled.json"; + writeAppsJson(path, {"Game One", "Game Two"}); + + auto parsed = proc::parse(path.string()); + ASSERT_TRUE(parsed.has_value()); + + const auto &apps = parsed->get_apps(); + ASSERT_EQ(apps.size(), 2U); + for (const auto &app : apps) { + EXPECT_NE(app.name, "Input Only"); + } + EXPECT_TRUE(proc::input_only_app_id.empty()); +} + +TEST_F(ProcessInputOnlyTest, Parse_KeepsExistingAppIdsStableWhenToggled) { + const auto path = test_dir / "apps_input_only_toggle.json"; + writeAppsJson(path, {"Game One", "Game Two"}); + + config::input.enable_input_only_mode = false; + auto disabled = proc::parse(path.string()); + ASSERT_TRUE(disabled.has_value()); + + config::input.enable_input_only_mode = true; + auto enabled = proc::parse(path.string()); + ASSERT_TRUE(enabled.has_value()); + + const auto &disabled_apps = disabled->get_apps(); + const auto &enabled_apps = enabled->get_apps(); + ASSERT_EQ(enabled_apps.size(), disabled_apps.size() + 1); + for (size_t i = 0; i < disabled_apps.size(); ++i) { + EXPECT_EQ(enabled_apps[i].id, disabled_apps[i].id); + } +} + +TEST_F(ProcessInputOnlyTest, IsInputOnlyRunning_FollowsPlaceholderLifetime) { + auto process = make_process({make_placeholder_ctx("777")}); + + EXPECT_FALSE(process.is_input_only_running()); + + ASSERT_EQ(process.execute(777, std::make_shared()), 0); + + // The placeholder launches no command, so it stays running via the placebo flag + EXPECT_EQ(process.running(), 777); + EXPECT_TRUE(process.is_input_only_running()); + + process.terminate(); + + EXPECT_EQ(process.running(), 0); + EXPECT_FALSE(process.is_input_only_running()); + EXPECT_TRUE(process.get_last_run_app_name().empty()); +} + +TEST_F(ProcessInputOnlyTest, IsInputOnlyRunning_FalseForRegularApp) { + auto process = make_process({make_regular_ctx("778")}); + + ASSERT_EQ(process.execute(778, std::make_shared()), 0); + EXPECT_EQ(process.running(), 778); + EXPECT_FALSE(process.is_input_only_running()); + + process.terminate(); + EXPECT_FALSE(process.is_input_only_running()); +} + +TEST_F(ProcessInputOnlyTest, IsInputOnlyRunning_IgnoredWhenModeToggledOffMidSession) { + auto process = make_process({make_placeholder_ctx("779")}); + ASSERT_EQ(process.execute(779, std::make_shared()), 0); + ASSERT_TRUE(process.is_input_only_running()); + + // Disabling the mode clears the cached id, but a running placeholder must stay recognized + config::input.enable_input_only_mode = false; + proc::input_only_app_id.clear(); + + EXPECT_TRUE(process.is_input_only_running()); + EXPECT_TRUE(proc::get_input_only_app_id().empty()); + + process.terminate(); + EXPECT_FALSE(process.is_input_only_running()); +} + +TEST_F(ProcessInputOnlyTest, ExecuteRealAppOverPlaceholderClearsInputOnlyState) { + std::vector apps; + apps.push_back(make_placeholder_ctx("780")); + apps.push_back(make_regular_ctx("781")); + + auto process = make_process(std::move(apps)); + ASSERT_EQ(process.execute(780, std::make_shared()), 0); + ASSERT_TRUE(process.is_input_only_running()); + + // Launching a real app terminates the placeholder, which must not leave the + // input-only state behind for the newly launched app. + ASSERT_EQ(process.execute(781, std::make_shared()), 0); + EXPECT_EQ(process.running(), 781); + EXPECT_FALSE(process.is_input_only_running()); + + process.terminate(); + EXPECT_FALSE(process.is_input_only_running()); +} + +TEST_F(ProcessInputOnlyTest, ResolveResumeState_SnapshotsAfterPlaceholderCleanup) { + auto saved_global_proc = std::move(proc::proc); + auto restore_global_proc = util::fail_guard([&saved_global_proc]() { + proc::proc = std::move(saved_global_proc); + }); + + auto process = make_process({make_placeholder_ctx("901")}); + ASSERT_EQ(process.execute(901, std::make_shared()), 0); + proc::proc = std::move(process); + + auto state = nvhttp::resolve_resume_state(); + EXPECT_EQ(state.app_id, 901); + EXPECT_TRUE(state.input_only); + + // The last input-only session's teardown terminates the placeholder. A resume taken + // before that cleanup would still see it and fall back to desktop capture. + proc::proc.terminate(); + + state = nvhttp::resolve_resume_state(); + EXPECT_EQ(state.app_id, 0); + EXPECT_FALSE(state.input_only); +} diff --git a/tests/unit/test_video.cpp b/tests/unit/test_video.cpp index 8bb2991d0da..3621cd7fa48 100644 --- a/tests/unit/test_video.cpp +++ b/tests/unit/test_video.cpp @@ -655,3 +655,171 @@ TEST_F(FrameTimestampQueueTest, ConsumesEntriesOnce) { EXPECT_EQ(ts(1), video::match_frame_timestamp(queue, 1)); EXPECT_EQ(std::nullopt, video::match_frame_timestamp(queue, 1)); } + +namespace { + /** + * @brief Point an image descriptor at a caller-owned buffer. + * + * @param img Descriptor to populate. + * @param data Buffer holding the image data. + * @param width Image width in pixels. + * @param height Image height in pixels. + * @param pixel_pitch Bytes per pixel reported by the capture backend. + * @param row_pitch Bytes between consecutive image rows. + */ + void bind_image(platf::img_t &img, std::uint8_t *data, int width, int height, int pixel_pitch, int row_pitch) { + img.data = data; + img.width = width; + img.height = height; + img.pixel_pitch = pixel_pitch; + img.row_pitch = row_pitch; + } + + /** + * @brief Image descriptor that exposes an opaque GPU resource through `data`. + */ + struct gpu_backed_img_t: platf::img_t { + /** + * @brief Report that `data` is not a CPU-writable buffer. + * + * @return Always false. + */ + bool is_cpu_buffer() const override { + return false; + } + }; +} // namespace + +TEST(FillBlankImageTest, PacksOpaqueBlackForBgra) { + std::array buffer; + buffer.fill(0x11); + + platf::img_t img; + bind_image(img, buffer.data(), 2, 2, 4, 2 * 4); + + video::fill_blank_image(img); + + for (size_t pixel = 0; pixel < 4; ++pixel) { + EXPECT_EQ(buffer[pixel * 4 + 0], 0x00); + EXPECT_EQ(buffer[pixel * 4 + 1], 0x00); + EXPECT_EQ(buffer[pixel * 4 + 2], 0x00); + EXPECT_EQ(buffer[pixel * 4 + 3], 0xFF); + } +} + +TEST(FillBlankImageTest, PreservesPaddingBytesAfterEachBgraRow) { + // Two rows of two BGRA pixels padded to 12 bytes: the trailing 4 bytes must stay untouched. + std::array buffer; + buffer.fill(0x5A); + + platf::img_t img; + bind_image(img, buffer.data(), 2, 2, 4, 12); + + video::fill_blank_image(img); + + for (int row = 0; row < 2; ++row) { + EXPECT_EQ(buffer[row * 12 + 0], 0x00); + EXPECT_EQ(buffer[row * 12 + 7], 0xFF); + EXPECT_EQ(buffer[row * 12 + 8], 0x5A); + EXPECT_EQ(buffer[row * 12 + 11], 0x5A); + } +} + +TEST(FillBlankImageTest, UsesNeutralChromaForNv12) { + // 2x2 NV12: a 4-byte Y plane followed by a 2-byte interleaved UV plane. + std::array buffer; + buffer.fill(0x33); + + platf::img_t img; + bind_image(img, buffer.data(), 2, 2, 1, 2); + + video::fill_blank_image(img); + + EXPECT_EQ(buffer[0], 0x00); + EXPECT_EQ(buffer[1], 0x00); + EXPECT_EQ(buffer[2], 0x00); + EXPECT_EQ(buffer[3], 0x00); + EXPECT_EQ(buffer[4], 0x80); + EXPECT_EQ(buffer[5], 0x80); +} + +TEST(FillBlankImageTest, InfersPixelPitchFromRowPitchWhenBackendOmitsIt) { + std::array buffer; + buffer.fill(0x77); + + platf::img_t img; + bind_image(img, buffer.data(), 2, 1, 0, 8); + + video::fill_blank_image(img); + + EXPECT_EQ(buffer[0], 0x00); + EXPECT_EQ(buffer[3], 0xFF); + EXPECT_EQ(buffer[4], 0x00); + EXPECT_EQ(buffer[7], 0xFF); +} + +TEST(FillBlankImageTest, LeavesUnusableDescriptorsAndUnknownLayoutsAlone) { + // An unsupported pixel pitch must not be guessed at. + std::array packed; + packed.fill(0x42); + + platf::img_t img; + bind_image(img, packed.data(), 2, 2, 2, 4); + + video::fill_blank_image(img); + + EXPECT_EQ(packed[0], 0x42); + EXPECT_EQ(packed[3], 0x42); + + // A descriptor without a buffer must be ignored rather than dereferenced. + platf::img_t without_data; + bind_image(without_data, nullptr, 2, 2, 4, 8); + video::fill_blank_image(without_data); + + // Degenerate dimensions must not divide by zero or run off the row. + std::array guard; + guard.fill(0x42); + + platf::img_t zero_width; + bind_image(zero_width, guard.data(), 0, 2, 0, 0); + video::fill_blank_image(zero_width); + + platf::img_t zero_height; + bind_image(zero_height, guard.data(), 2, 0, 4, 8); + video::fill_blank_image(zero_height); + + EXPECT_EQ(guard[0], 0x42); + EXPECT_EQ(guard[3], 0x42); + + // A row pitch smaller than width * pixel_pitch would run past the buffer. + std::array tight; + tight.fill(0x42); + + platf::img_t tight_bgra; + bind_image(tight_bgra, tight.data(), 2, 2, 4, 4); + video::fill_blank_image(tight_bgra); + + platf::img_t tight_nv12; + bind_image(tight_nv12, tight.data(), 2, 2, 1, 1); + video::fill_blank_image(tight_nv12); + + for (const auto byte : tight) { + EXPECT_EQ(byte, 0x42); + } +} + +TEST(FillBlankImageTest, SkipsOpaqueGpuBackedImages) { + // On the Windows VRAM path `data` is an ID3D11Texture2D pointer, so writing pixels + // through it would corrupt the texture object instead of filling the frame. + std::array buffer; + buffer.fill(0x3C); + + gpu_backed_img_t img; + bind_image(img, buffer.data(), 2, 2, 4, 2 * 4); + + video::fill_blank_image(img); + + for (const auto byte : buffer) { + EXPECT_EQ(byte, 0x3C); + } +} diff --git a/tests/web/Configuration.test.js b/tests/web/Configuration.test.js index 04f7a2aa127..b4bcf29f912 100644 --- a/tests/web/Configuration.test.js +++ b/tests/web/Configuration.test.js @@ -237,4 +237,44 @@ describe('gamepad input configuration', () => { ]) expect(wrapper.get('#gamepad').element.value).toBe('auto') }) + + it('toggles input only mode between enabled and disabled', async () => { + const config = { + controller: 'enabled', + gamepad: 'auto', + gamepad_driver: '', + keybindings: '[]', + motion_as_ds4: 'enabled', + touchpad_as_ds4: 'enabled', + ds4_back_as_touchpad_click: 'enabled', + virtualhid_randomize_mac: 'enabled', + keyboard: 'enabled', + key_repeat_delay: 500, + key_repeat_frequency: 24.9, + always_send_scancodes: 'enabled', + key_rightalt_to_key_win: 'disabled', + mouse: 'enabled', + high_resolution_scrolling: 'enabled', + native_pen_touch: 'enabled', + enable_input_only_mode: 'disabled', + } + + const wrapper = mount(Inputs, { + props: { platform: 'linux', config }, + global: { + mocks: { $t: key => key }, + stubs: { Checkbox: false, VirtualKeyCodeSelect: true }, + }, + }) + + const input = wrapper.get('#enable_input_only_mode') + + expect(input.element.checked).toBe(false) + + await input.setValue(true) + expect(config.enable_input_only_mode).toBe('enabled') + + await input.setValue(false) + expect(config.enable_input_only_mode).toBe('disabled') + }) })