From 7e9023faadab0e8f9b94d0cda62fdabf7cea970d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 26 Jun 2026 08:12:53 +0000 Subject: [PATCH] feat(gamestream): launch apps on Windows + Linux non-gamescope hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameStream's apps.json `cmd` is delivered via set_launch_command, which ONLY the Linux gamescope backend nests. On Windows (no gamescope) and Linux kwin/mutter/wlroots (which stream the existing desktop) the command was silently dropped. Now, after capture is live, stream.rs spawns it via library::launch_gamestream_command for those backends — Windows: into the interactive USER session (spawn_in_active_session, since the host is SYSTEM); Linux: a plain `sh -c` spawn into the host's own graphical session so the app lands on the streamed (primary) output. Linux gamescope keeps nesting via set_launch_command and is skipped here to avoid a double launch. The command is operator-typed apps.json (trusted), never client-set. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../punktfunk-host/src/gamestream/stream.rs | 19 +++++++++++ crates/punktfunk-host/src/library.rs | 32 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index eb41bcd..e76e126 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -141,6 +141,25 @@ fn run( ) .context("capture virtual output")?; capturer.set_active(true); + // Launch the app's command now that capture is live, for the backends that DON'T nest it via + // set_launch_command above: Windows (no gamescope) and Linux kwin/mutter/wlroots (which stream + // the existing desktop, so the app must be spawned into the session to land on the streamed + // output). Linux gamescope already nested it via set_launch_command, so skip it there. + #[cfg(windows)] + let launch_here = true; + #[cfg(target_os = "linux")] + let launch_here = compositor != crate::vdisplay::Compositor::Gamescope; + #[cfg(any(windows, target_os = "linux"))] + if launch_here { + if let Some(cmd) = app + .and_then(|a| a.cmd.as_deref()) + .filter(|c| !c.trim().is_empty()) + { + if let Err(e) = crate::library::launch_gamestream_command(cmd) { + tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app"); + } + } + } return stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range); } diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index 49f5da9..674caf1 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -1436,6 +1436,38 @@ fn steam_exe() -> Option { None } +/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the live +/// session, AFTER capture is up. Used by the GameStream path for the backends that DON'T nest the +/// command via [`VirtualDisplay::set_launch_command`]: Windows (no gamescope) and Linux +/// kwin/mutter/wlroots (which stream the existing desktop). The caller skips this for Linux gamescope, +/// which already nested it. On Windows it runs in the interactive USER session (the host is SYSTEM); +/// on Linux the host is already inside the user's graphical session, so a plain spawn lands the app on +/// the streamed (primary) output. +#[cfg(any(windows, target_os = "linux"))] +pub fn launch_gamestream_command(cmd: &str) -> Result<()> { + let cmd = cmd.trim(); + anyhow::ensure!(!cmd.is_empty(), "empty command"); + #[cfg(windows)] + { + // cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a + // client-influenced string (same trust as the custom-store `command` kind). + let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None) + .context("spawn gamestream command in the interactive session")?; + tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session"); + Ok(()) + } + #[cfg(target_os = "linux")] + { + let child = std::process::Command::new("sh") + .arg("-c") + .arg(cmd) + .spawn() + .context("spawn gamestream command")?; + tracing::info!(command = %cmd, pid = child.id(), "gamestream: launched app into the session"); + Ok(()) + } +} + /// The full library: every store's titles merged + the custom entries, sorted by title. pub fn all_games() -> Vec { let mut games = SteamProvider.list();