Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,32 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin
</tr>
</table>

### enable_input_only_mode

<table>
<tr>
<td>Description</td>
<td colspan="2">
When enabled, Sunshine adds a special "Input Only" app to its application list.
<br>
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.
</td>
</tr>
<tr>
<td>Default</td>
<td colspan="2">@code{}
disabled
@endcode</td>
</tr>
<tr>
<td>Example</td>
<td colspan="2">@code{}
enable_input_only_mode = enabled
@endcode</td>
</tr>
</table>

### keybindings

<table>
Expand Down
2 changes: 2 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,7 @@ namespace config {
true, // always send scancodes
true, // high resolution scrolling
true, // native pen/touch support
false, // enable input only mode
};

/**
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
101 changes: 81 additions & 20 deletions src/nvhttp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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.<xmlattr>.status_code", 400);
tree.put("root.<xmlattr>.status_message", "An app is already running on this host");
Expand All @@ -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
Expand All @@ -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.<xmlattr>.status_code", err);
tree.put("root.<xmlattr>.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.<xmlattr>.status_code", err);
Expand Down Expand Up @@ -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.<xmlattr>.status_code", 503);
tree.put("root.<xmlattr>.status_message", "No running app to resume");

return;
}

auto args = request->parse_query_string();
if (
args.find("rikey"s) == std::end(args) ||
Expand All @@ -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.<xmlattr>.status_code", 503);
tree.put("root.<xmlattr>.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
Expand Down Expand Up @@ -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.
*
Expand Down
20 changes: 20 additions & 0 deletions src/nvhttp.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
14 changes: 14 additions & 0 deletions src/platform/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,20 @@ namespace platf {

std::optional<std::chrono::steady_clock::time_point> 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.
*/
Expand Down
9 changes: 9 additions & 0 deletions src/platform/windows/display_vram.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading