From e7b07d2363dc3f241c676c863e9fb88cb581235f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 1 Jul 2026 22:02:52 +0000 Subject: [PATCH] fix(host): make client game launches work on every Linux compositor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-initiated launches (Hello.launch / GameStream applist) were only wired to gamescope's bare-spawn path via the process-global PUNKTFUNK_GAMESCOPE_APP env — which leaked across sessions, was never read by kwin/mutter/wlroots (launch was a silent no-op there), and was unreachable on gamescope anyway because apply_input_env unconditionally defaulted to the managed session (which bails on non-Bazzite/SteamOS boxes and ignores the launch command in all its modes). - Thread the launch per-session: resolve the library id at handshake, carry it on SessionContext (Windows: id; else: resolved command), and hand it to the backend instance via set_launch_command — the global env write is gone (the env stays as an operator fallback in spawn). - Gamescope sub-mode ladder (pick_gamescope_mode, pure + unit-tested): managed only when session-plus/SteamOS infra exists, attach for an explicit request or a foreign (non-host-descendant) gamescope, else bare spawn — which nests the launch and is now reachable on plain distros instead of the guaranteed managed-mode bail. - launch_session_command: one launch entry point for both planes once capture is live — desktop compositors plain-spawn into the retargeted session (the virtual output is primary); managed/attached gamescope spawns with the live session's DISPLAY/GAMESCOPE_WAYLAND_DISPLAY discovered from /proc (steam:// URIs also forward over Steam's own pipe). launch_is_nested gates bare spawn against double-launching. - GameStream unified onto the same dispatch; also nests library-id picks into gamescope (previously only apps.json cmd was nested). Validated live on the dev box up to the missing-GPU wall: handshake resolution, Spawn sub-mode on plain Ubuntu, gamescope spawned with the command nested. On-glass validation (kwin spawn on the streamed output, Bazzite/Deck managed forward) pending GPU reattach. Co-Authored-By: Claude Fable 5 --- .../punktfunk-host/src/gamestream/stream.rs | 42 +++-- crates/punktfunk-host/src/library.rs | 116 ++++++++----- crates/punktfunk-host/src/punktfunk1.rs | 99 ++++++----- crates/punktfunk-host/src/vdisplay.rs | 147 +++++++++++++--- .../src/vdisplay/linux/gamescope.rs | 159 ++++++++++++++++++ 5 files changed, 443 insertions(+), 120 deletions(-) diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 74ab89a..9ee1a58 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -132,19 +132,15 @@ fn run( "video source: virtual display (native client resolution)" ); // 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. + // set_launch_command above: Windows (no gamescope) and, on Linux, everything but gamescope's + // bare-spawn sub-mode (kwin/mutter/wlroots stream the existing desktop; a managed/attached + // gamescope is a running session to launch INTO — `launch_session_command` routes both). + // A library title (Steam/Epic/GOG/Xbox/custom, surfaced in /applist) carries its + // store-qualified id — resolved against the host's OWN library (the client can only pick an + // existing title, never inject a command). An apps.json entry instead carries an + // operator-typed `cmd`. Library id wins when both are set. #[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 { - // A library title (Steam/Epic/GOG/Xbox/custom, surfaced in /applist) carries its - // store-qualified id — resolve + launch it against the host's OWN library (the client can - // only pick an existing title, never inject a command). An apps.json entry instead carries - // an operator-typed `cmd`. Library id wins when both are set. + { if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) { if let Err(e) = crate::library::launch_gamestream_library(lib_id) { tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title"); @@ -158,6 +154,17 @@ fn run( } } } + #[cfg(target_os = "linux")] + if !crate::vdisplay::launch_is_nested(compositor) { + if let Some(cmd) = crate::library::resolve_session_launch( + app.and_then(|a| a.library_id.as_deref()), + app.and_then(|a| a.cmd.as_deref()), + ) { + if let Err(e) = crate::library::launch_session_command(compositor, &cmd) { + tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app"); + } + } + } // Rebuild closure: re-open the source on a mid-stream capture loss, RE-DETECTING the live // compositor — so a Desktop<->Game switch (at the client's fixed mode) is FOLLOWED in place // without a Moonlight reconnect. (A resolution change can't be followed mid-stream on @@ -248,7 +255,16 @@ fn open_gs_virtual_source( }; let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?; // Carry the resolved launch command on the backend instance (per-session) rather than a - // process-global env var, so concurrent sessions can't stomp each other's launch target. + // process-global env var, so concurrent sessions can't stomp each other's launch target. On + // Linux resolve a library-id selection to its command too, so gamescope's bare spawn nests a + // library title exactly like an apps.json command (it previously nested only `cmd`, silently + // dropping library picks). + #[cfg(target_os = "linux")] + vd.set_launch_command(crate::library::resolve_session_launch( + app.and_then(|a| a.library_id.as_deref()), + app.and_then(|a| a.cmd.as_deref()), + )); + #[cfg(not(target_os = "linux"))] vd.set_launch_command(app.and_then(|a| a.cmd.clone())); let vout = vd .create(punktfunk_core::Mode { diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index 582e035..baaf490 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -1639,55 +1639,89 @@ 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"))] +/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the +/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go +/// through the compositor-aware [`launch_session_command`] instead. +#[cfg(windows)] 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(()) - } + // 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(()) } /// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried -/// on the `AppEntry`, resolved from the numeric Moonlight appid). Windows spawns it into the interactive -/// user session ([`launch_title`]); Linux resolves its shell command ([`launch_command`]) and runs it -/// into the live session ([`launch_gamestream_command`]). The id is resolved against the host's OWN -/// library, so a client can only ever pick an existing title — never inject a command. -#[cfg(any(windows, target_os = "linux"))] +/// on the `AppEntry`, resolved from the numeric Moonlight appid) into the interactive Windows user +/// session ([`launch_title`]). The id is resolved against the host's OWN library, so a client can +/// only ever pick an existing title — never inject a command. Linux resolves the id via +/// [`launch_command`] and goes through [`launch_session_command`] instead. +#[cfg(windows)] pub fn launch_gamestream_library(id: &str) -> Result<()> { - #[cfg(windows)] - { - launch_title(id) - } - #[cfg(target_os = "linux")] - { - let cmd = launch_command(id) - .ok_or_else(|| anyhow::anyhow!("library id '{id}' has no launch recipe"))?; - launch_gamestream_command(&cmd) + launch_title(id) +} + +/// Launch a resolved shell command into the **live Linux session** for the session's compositor — +/// the one launch entry point shared by the native (punktfunk/1) and GameStream planes, called +/// AFTER capture is up so the app renders onto the streamed output. The command is host-resolved +/// (a library id via [`launch_command`], or an operator-typed apps.json/custom command) — never a +/// client-sent string. Best-effort by contract: a failure leaves the user on the (streamed) +/// desktop/session rather than tearing the stream down. +/// +/// * **KWin / Mutter / wlroots** — the host runs inside the user's graphical session (the process +/// env was retargeted at it by `apply_session_env`, and the per-session virtual output is +/// promoted primary), so a plain spawn lands the app on the streamed output. +/// * **gamescope (managed / SteamOS / attach)** — the app must open *inside* the running gamescope +/// session: spawned with the session's own `DISPLAY`/Wayland env +/// ([`crate::vdisplay::launch_into_gamescope_session`]). A `steam steam://…` command additionally +/// forwards over the running Steam instance's own pipe, so the dominant Steam case is +/// env-independent. +/// * **gamescope (bare spawn)** — not routed here: the command was nested into the fresh gamescope +/// via `set_launch_command` (the caller gates on `vdisplay::launch_is_nested`). +#[cfg(target_os = "linux")] +pub fn launch_session_command(compositor: crate::vdisplay::Compositor, cmd: &str) -> Result<()> { + let cmd = cmd.trim(); + anyhow::ensure!(!cmd.is_empty(), "empty command"); + let child = match compositor { + crate::vdisplay::Compositor::Gamescope => { + crate::vdisplay::launch_into_gamescope_session(cmd)? + } + _ => std::process::Command::new("sh") + .arg("-c") + .arg(cmd) + .spawn() + .context("spawn launch command")?, + }; + tracing::info!( + command = %cmd, + pid = child.id(), + compositor = compositor.id(), + "launched app into the live session" + ); + Ok(()) +} + +/// Resolve the launch command for a session app selection on Linux: a store-qualified library id +/// (from either plane) wins, else the operator-typed command. `None` = nothing to launch (or an +/// unknown/recipe-less id — warned, so a client picking a stale title sees why nothing started). +#[cfg(target_os = "linux")] +pub fn resolve_session_launch(library_id: Option<&str>, command: Option<&str>) -> Option { + if let Some(id) = library_id { + match launch_command(id) { + Some(cmd) => return Some(cmd), + None => tracing::warn!( + launch_id = id, + "requested launch id not in this host's library (or no launch recipe) — ignoring" + ), + } } + command + .map(str::trim) + .filter(|c| !c.is_empty()) + .map(str::to_string) } /// The full library: every store's titles merged + the custom entries, sorted by title. diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 9afc1e4..8e82d6e 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -680,36 +680,12 @@ async fn serve_session( Punktfunk1Source::Synthetic => None, }; - // Resolve a requested library launch (the client sends only the store-qualified id; - // we look it up in OUR library so a client can't inject a command). The bare-spawn gamescope - // backend picks this up via the `PUNKTFUNK_GAMESCOPE_APP` env fallback in `spawn` (on a shared - // desktop / attach-to-existing session it's a harmless no-op). This is the process-global env - // path; the write is serialized via `vdisplay::with_env_lock` so concurrent native-session - // handshakes can't race the `set_var` (security-review 2026-06-28 #7). The remaining - // cross-session *value* confusion (B's launch id stomping A's pending gamescope spawn) wants - // the command resolved into the per-session VirtualDisplay via `set_launch_command` (as the - // GameStream path does) — a follow-up; the data-race UB is closed here. - if let Some(id) = hello.launch.as_deref() { - // Linux: resolve the id to a gamescope-nested command and stash it in the env the - // gamescope backend reads. Windows has no gamescope to nest into — the data plane launches - // the title into the interactive user session via `library::launch_title` once capture is - // live (threaded as `SessionContext.launch` below), so there is nothing to do here. - #[cfg(not(windows))] - match crate::library::launch_command(id) { - Some(cmd) => { - tracing::info!(launch_id = id, command = %cmd, "launching library title"); - crate::vdisplay::with_env_lock(|| { - std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd) - }); - } - None => tracing::warn!( - launch_id = id, - "client requested a launch id not in this host's library — ignoring" - ), - } - #[cfg(windows)] - let _ = id; - } + // A requested library launch (the client sends only the store-qualified id; we look it up + // in OUR library so a client can't inject a command) is resolved below — after the Welcome, + // where it's threaded per-session into the data plane as `SessionContext.launch` (no + // process-global env: the old `PUNKTFUNK_GAMESCOPE_APP` write leaked across sessions, and + // only gamescope's bare-spawn path ever read it, so launches on every other backend were + // silently dropped). // Resolve the client's gamepad-backend preference (pure env/cfg check — no probing // needed; the actual pads are created lazily by the input thread). @@ -1101,10 +1077,29 @@ async fn serve_session( let source = opts.source; let (seconds, frames) = (opts.seconds, opts.frames); let mode = hello.mode; - // Windows: the store-qualified launch id, threaded into the data plane so the title can be - // launched into the interactive session once capture is live (no gamescope nesting on Windows). + // The session's launch, threaded into the data plane. Windows carries the store-qualified id + // (spawned into the interactive user session once capture is live); other hosts resolve the id + // to its shell command HERE against the host's own library — a client can only ever pick an + // existing title, never send a command — and the data plane runs it per-backend (nested into a + // bare-spawn gamescope, or spawned into the live session once capture is up). #[cfg(target_os = "windows")] let launch_for_dp = hello.launch.clone(); + #[cfg(not(target_os = "windows"))] + let launch_for_dp = hello.launch.as_deref().and_then(|id| { + match crate::library::launch_command(id) { + Some(cmd) => { + tracing::info!(launch_id = id, command = %cmd, "resolved library launch for this session"); + Some(cmd) + } + None => { + tracing::warn!( + launch_id = id, + "client requested a launch id not in this host's library — ignoring" + ); + None + } + } + }); let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default) let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated) // Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the @@ -1180,7 +1175,6 @@ async fn serve_session( conn: conn_stream, stats: stats_dp, client_label, - #[cfg(target_os = "windows")] launch: launch_for_dp, }) } @@ -2108,7 +2102,8 @@ fn resolve_compositor(pref: CompositorPref) -> Result = available.iter().map(|c| c.id()).collect(); @@ -2747,10 +2742,10 @@ struct SessionContext { /// Short client label (cert-fingerprint prefix, else peer IP) seeded into the capture meta on /// the first armed stats registration. client_label: String, - /// Windows: the store-qualified library id to launch into the interactive user session once - /// capture is live (no gamescope nesting on Windows). `None` = no launch requested. Linux uses the - /// gamescope `PUNKTFUNK_GAMESCOPE_APP` path resolved at handshake, so this field is Windows-only. - #[cfg(target_os = "windows")] + /// The session's requested launch, `None` = none. On Windows the store-qualified library id + /// (spawned into the interactive user session once capture is live); on other hosts the shell + /// command already resolved against the host's own library — nested into gamescope's bare spawn + /// via `set_launch_command`, or spawned into the live session once capture is up. launch: Option, } @@ -2785,7 +2780,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { conn, stats, client_label, - #[cfg(target_os = "windows")] launch, } = ctx; tracing::info!( @@ -2804,6 +2798,12 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends // and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates). vd.set_client_identity(endpoint::peer_fingerprint(&conn)); + // Per-session launch (non-Windows): hand the resolved command to the backend instance so + // gamescope's bare spawn nests it — per-instance, no process-global env, so concurrent sessions + // can't stomp each other's launch target. The other backends' default `set_launch_command` is a + // no-op; they get the command spawned into the live session after capture is up (below). + #[cfg(not(target_os = "windows"))] + vd.set_launch_command(launch.clone()); // IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a // reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it // to release its monitor (instead of tearing a monitor out from under a still-live session), and @@ -2819,16 +2819,29 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { #[cfg(target_os = "windows")] drop(_idd_setup_guard); - // Windows: capture is live — launch the requested library title into the - // interactive user session so it renders onto the captured desktop and grabs foreground. Linux - // nests its launch in gamescope instead (the handshake `PUNKTFUNK_GAMESCOPE_APP` path). Best-effort: - // a launch failure (no recipe for the kind, no interactive user) leaves the user on the desktop. + // Capture is live — launch the requested title so it renders onto the streamed output and + // grabs focus. Windows spawns the library id into the interactive user session; Linux spawns + // the resolved command into the live session for every backend that didn't already nest it + // (gamescope's bare spawn ran it inside the fresh gamescope — launching again would start it + // twice). Best-effort: a launch failure (no recipe, launcher missing, no interactive user) + // leaves the user on the streamed desktop/session, never tears the stream down. Launched ONCE + // here — the mid-stream rebuild paths below must not re-spawn it. #[cfg(target_os = "windows")] if let Some(id) = launch.as_deref() { if let Err(e) = crate::library::launch_title(id) { tracing::warn!(launch_id = id, error = %e, "could not launch requested library title"); } } + #[cfg(target_os = "linux")] + if let Some(cmd) = launch.as_deref() { + if crate::vdisplay::launch_is_nested(compositor) { + tracing::info!(command = %cmd, "launch nested into the per-session gamescope"); + } else if let Err(e) = crate::library::launch_session_command(compositor, cmd) { + tracing::warn!(command = %cmd, error = %e, "could not launch requested title into the session"); + } + } + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + let _ = &launch; let perf = crate::config::config().perf; // Microburst cap (applied in send_loop/paced_submit): a frame ≤ this bursts out immediately; diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index d5aad54..bbc5c0d 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -369,9 +369,10 @@ fn find_wayland_socket(runtime: &str, uid: u32) -> Option { /// the default concurrent native sessions each running `resolve_compositor` in its own /// `spawn_blocking`, the per-session env retargeting would otherwise race and could crash the host /// (security-review 2026-06-28 #7). Every env write on the setup path takes this lock; steady-state -/// streaming reads cached config, not env. This removes the memory-unsafety; it is NOT a full fix -/// for cross-session env *value* confusion (that needs per-session `SessionContext` threading, as the -/// GameStream/Windows path already does via `set_launch_command`). +/// streaming reads cached config, not env. This removes the memory-unsafety; the launch command is +/// additionally threaded per-session (`SessionContext.launch` → `set_launch_command`) so it never +/// rides the process env at all — the remaining knobs here (session retarget, gamescope sub-mode) +/// still carry a cross-session *value* confusion window inherent to a process-global env. pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// Run `f` with [`ENV_LOCK`] held. Use around any `set_var`/`remove_var` on the session-setup path. @@ -384,7 +385,7 @@ pub fn with_env_lock(f: impl FnOnce() -> R) -> R { /// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` / /// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so /// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and -/// re-applies. Same `set_var` discipline used for `PUNKTFUNK_GAMESCOPE_APP` on the launch path. +/// re-applies. #[cfg(target_os = "linux")] pub fn apply_session_env(active: &ActiveSession) { let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -468,14 +469,57 @@ pub fn settle_desktop_portal(chosen: Compositor) { #[cfg(not(target_os = "linux"))] pub fn settle_desktop_portal(_chosen: Compositor) {} +/// How a gamescope-backed session is realized. Chosen per connect by [`pick_gamescope_mode`], +/// written into the env knobs `GamescopeDisplay::create` dispatches on. +#[cfg(target_os = "linux")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GamescopeMode { + /// Host-managed `gamescope-session-plus` / SteamOS session at the client's mode. + Managed, + /// Attach to an already-running gamescope (capture + inject, no lifecycle ownership). + Attach, + /// Bare-spawn a headless gamescope per session, nesting the session's launch command. + Spawn, +} + +/// Pure sub-mode ladder for gamescope (unit-testable — the env/probe inputs are parameters): +/// explicit `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed; explicit ATTACH/NODE forces attach; an +/// operator-set `PUNKTFUNK_GAMESCOPE_SESSION` keeps managed; otherwise managed only **when the box +/// actually has the session infrastructure** (gamescope-session-plus / SteamOS — the old code +/// defaulted to managed unconditionally and then bailed on a plain distro, killing the session); +/// a foreign (not host-spawned) gamescope on an infra-less box is attached to; and the final +/// default is a per-session bare spawn — the path that nests the client's launch command. +#[cfg(target_os = "linux")] +fn pick_gamescope_mode( + force_managed: bool, + attach_env: bool, + node_env: bool, + session_env: bool, + managed_infra: bool, + foreign_gamescope: bool, +) -> GamescopeMode { + if force_managed { + GamescopeMode::Managed + } else if attach_env || node_env { + GamescopeMode::Attach + } else if session_env || managed_infra { + GamescopeMode::Managed + } else if foreign_gamescope { + GamescopeMode::Attach + } else { + GamescopeMode::Spawn + } +} + /// Route input to match the chosen video backend (they must not diverge), via the highest-priority -/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope, the **default is a managed -/// session at the client's mode** (tears the TV's autologin down on connect; restored on a debounced -/// idle) — so the client gets ITS resolution (capture == encode == client mode), not the TV's, and a -/// quick reconnect reuses the warm session (no churn). Opt out to **attach** (mirror the running TV -/// session at its own mode, gaming stays live on the panel, no Steam restart) with -/// `PUNKTFUNK_GAMESCOPE_ATTACH`; an explicit `PUNKTFUNK_GAMESCOPE_NODE` also implies attach, and -/// `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over either. +/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope the sub-mode ladder +/// ([`pick_gamescope_mode`]) selects **managed** (a host-managed session at the client's mode — +/// tears the TV's autologin down on connect, restored on a debounced idle; only where +/// session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at its own mode; +/// explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the fallback for a +/// foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless gamescope +/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED` +/// forces managed over all of it. #[cfg(target_os = "linux")] pub fn apply_input_env(chosen: Compositor) { let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -490,26 +534,61 @@ pub fn apply_input_env(chosen: Compositor) { }; std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend); if chosen == Compositor::Gamescope { - let force_managed = std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(); - let attach = !force_managed - && (std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some() - || std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some()); - if attach { - std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION"); - if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() { - std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto"); + let mode = pick_gamescope_mode( + std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(), + std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(), + std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(), + std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_some(), + gamescope::managed_session_available(), + gamescope::foreign_gamescope_running(), + ); + tracing::info!(?mode, "gamescope sub-mode"); + match mode { + GamescopeMode::Attach => { + std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION"); + if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() { + std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto"); + } } - } else { - if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() { - std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam"); + GamescopeMode::Managed => { + if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() { + std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam"); + } + std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE"); + } + GamescopeMode::Spawn => { + // Bare spawn: `create` must fall through to the spawn path, so neither knob may + // linger from an earlier connect's managed/attach selection. + std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION"); + std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE"); } - std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE"); } } } #[cfg(not(target_os = "linux"))] pub fn apply_input_env(_chosen: Compositor) {} +/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare +/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command +/// into the session — it would start twice. Read AFTER [`apply_input_env`] resolved the gamescope +/// sub-mode (the env knobs are that resolution's output). +#[cfg(target_os = "linux")] +pub fn launch_is_nested(compositor: Compositor) -> bool { + compositor == Compositor::Gamescope + && with_env_lock(|| { + std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() + && std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() + }) +} + +/// Launch `cmd` into the live gamescope session (managed/attach — see +/// [`gamescope::launch_into_session`]). Split out so `library.rs` doesn't reach into the private +/// backend module. +#[cfg(target_os = "linux")] +pub fn launch_into_gamescope_session(cmd: &str) -> Result { + gamescope::launch_into_session(cmd) +} + /// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing /// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box /// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read. @@ -692,6 +771,28 @@ mod tests { assert_eq!(compositor_for_kind(ActiveKind::None), None); } + #[cfg(target_os = "linux")] + #[test] + fn gamescope_mode_ladder() { + use GamescopeMode::*; + let pick = pick_gamescope_mode; + // (force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope) + // Plain distro, nothing running: bare spawn — the path that nests the launch command. + assert_eq!(pick(false, false, false, false, false, false), Spawn); + // Bazzite/SteamOS (session infra present): managed, as validated live. + assert_eq!(pick(false, false, false, false, true, false), Managed); + assert_eq!(pick(false, false, false, false, true, true), Managed); + // Foreign gamescope on an infra-less box: attach and mirror it. + assert_eq!(pick(false, false, false, false, false, true), Attach); + // Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra. + assert_eq!(pick(false, false, false, true, false, false), Managed); + // Explicit attach/node wins over infra… + assert_eq!(pick(false, true, false, false, true, false), Attach); + assert_eq!(pick(false, false, true, true, true, false), Attach); + // …and force-managed wins over everything. + assert_eq!(pick(true, true, true, false, false, false), Managed); + } + #[test] fn detect_active_session_is_side_effect_free_and_terminates() { // A pure probe of /proc + the runtime dir: it must not panic and must return promptly on diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs index 515b6b4..9880452 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs @@ -233,6 +233,165 @@ fn steamos_session_present() -> bool { && !std::path::Path::new(SESSION_PLUS_BIN).exists() } +/// Does this box have the infrastructure the MANAGED gamescope mode drives — Bazzite's +/// `gamescope-session-plus` or SteamOS's `gamescope-session`? The sub-mode ladder +/// ([`crate::vdisplay::apply_input_env`]) only defaults to managed when this is true; a plain +/// distro (neither present) falls through to the bare-spawn path instead of the old behaviour of +/// defaulting to managed and then bailing on the missing session script. +pub fn managed_session_available() -> bool { + std::path::Path::new(SESSION_PLUS_BIN).exists() + || std::path::Path::new(STEAMOS_SESSION_BIN).exists() +} + +/// Is a gamescope WE DIDN'T SPAWN running for our uid right now? Used by the sub-mode ladder to +/// pick ATTACH (mirror the foreign session) over a bare spawn on a box without managed-session +/// infra. Our own per-session bare-spawn gamescopes are children of this host process — excluded by +/// walking each candidate's ppid chain — so one client's nested gamescope never makes the next +/// client attach to it. +pub fn foreign_gamescope_running() -> bool { + // SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory. + let uid = unsafe { libc::getuid() }; + let our_pid = std::process::id(); + let Ok(entries) = std::fs::read_dir("/proc") else { + return false; + }; + for e in entries.flatten() { + let name = e.file_name(); + let Some(pid_str) = name.to_str() else { + continue; + }; + let Ok(pid) = pid_str.parse::() else { + continue; + }; + let Ok(md) = std::fs::metadata(e.path()) else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if md.uid() != uid { + continue; + } + let Ok(comm) = std::fs::read_to_string(e.path().join("comm")) else { + continue; + }; + if !matches!(comm.trim(), "gamescope" | "gamescope-wl") { + continue; + } + if !descends_from(pid, our_pid) { + return true; + } + } + false +} + +/// Is `pid` a descendant of (or equal to) `ancestor`? Walks the ppid chain via `/proc//stat` +/// with a hop cap so a racing/exiting process can't loop us. +fn descends_from(mut pid: u32, ancestor: u32) -> bool { + for _ in 0..64 { + if pid == ancestor { + return true; + } + if pid <= 1 { + return false; + } + let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { + return false; + }; + // Field 4 (ppid) follows the parenthesized comm — split after the LAST ')' since comm can + // itself contain parentheses. + let Some(rest) = stat.rsplit_once(')').map(|(_, r)| r) else { + return false; + }; + let Some(ppid) = rest.split_whitespace().nth(1).and_then(|s| s.parse().ok()) else { + return false; + }; + pid = ppid; + } + false +} + +/// Launch `cmd` INTO the live gamescope session (the managed / SteamOS / attach modes, where the +/// session already exists and [`spawn`]'s nesting doesn't apply). The child gets the session's own +/// `DISPLAY` (gamescope's Xwayland) and Wayland socket, discovered from a process already inside the +/// session — so X11 and Wayland clients alike land on the streamed gamescope output. Discovery is +/// best-effort: without it we still spawn with the host env and warn (a `steam steam://…` launch +/// still works there — the running Steam instance picks the URI up over its own pipe, no display +/// env needed). +pub fn launch_into_session(cmd: &str) -> Result { + let mut c = Command::new("sh"); + c.arg("-c").arg(cmd); + match discover_session_display_env() { + Some((x11, wayland)) => { + tracing::info!( + command = %cmd, + x11_display = x11.as_deref().unwrap_or("-"), + wayland = wayland.as_deref().unwrap_or("-"), + "gamescope: launching into the live session" + ); + if let Some(d) = x11 { + c.env("DISPLAY", d); + } + if let Some(w) = wayland { + c.env("WAYLAND_DISPLAY", w); + } + } + None => tracing::warn!( + command = %cmd, + "gamescope: could not discover the session's display env — spawning with the host env \ + (a `steam steam://…` launch still reaches the running Steam; other apps may not land \ + in the session)" + ), + } + c.spawn() + .context("spawn launch command into gamescope session") +} + +/// Find the live gamescope session's `(DISPLAY, WAYLAND_DISPLAY)` by scanning same-uid processes +/// for one whose environment carries `GAMESCOPE_WAYLAND_DISPLAY` (gamescope sets it for everything +/// it runs — Steam, the game, our own nested `sh`). The Wayland value returned is that gamescope +/// socket; `DISPLAY` is the nested Xwayland. Either can be individually absent. +fn discover_session_display_env() -> Option<(Option, Option)> { + // SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory. + let uid = unsafe { libc::getuid() }; + for e in std::fs::read_dir("/proc").ok()?.flatten() { + let name = e.file_name(); + let Some(pid_str) = name.to_str() else { + continue; + }; + if !pid_str.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + let Ok(md) = std::fs::metadata(e.path()) else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if md.uid() != uid { + continue; + } + let Ok(raw) = std::fs::read(e.path().join("environ")) else { + continue; + }; + let mut display = None; + let mut gs_wayland = None; + for kv in raw.split(|&b| b == 0) { + let kv = String::from_utf8_lossy(kv); + if let Some(v) = kv.strip_prefix("GAMESCOPE_WAYLAND_DISPLAY=") { + if !v.is_empty() { + gs_wayland = Some(v.to_string()); + } + } else if let Some(v) = kv.strip_prefix("DISPLAY=") { + if !v.is_empty() { + display = Some(v.to_string()); + } + } + } + // Only a process INSIDE a gamescope session (it has the marker var) is a valid source. + if gs_wayland.is_some() { + return Some((display, gs_wayland)); + } + } + None +} + /// Run a `systemctl --user` subcommand best-effort — a failure just means the session won't change, /// which the caller's node-wait surfaces. fn systemctl_user(args: &[&str]) {