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')
+ })
})