From b89dbfa979f9e87848dade5d144f45c60adca664 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 18 Jul 2026 16:24:34 +0200 Subject: [PATCH] feat(gamescope): end dedicated stream on Steam game exit + auto --steam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedicated single-game sessions now end cleanly when the launched game quits, and Steam launches get gamescope's Steam integration without an operator env knob. End-on-exit: the existing APP_EXITED close only fired when gamescope's PipeWire node vanished, which never happens for Steam — the nested `steam` is the resident singleton client and stays up after a game quits, so gamescope (and its node) never die and the stream sat on a hidden Steam session forever. Add a Steam AppId reaper watcher (pf-vdisplay .../gamescope/discovery.rs): steam_appid_from_launch() parses steam://rungameid/; wait_for_steam_game_exit() waits for the game to start (<=300s grace) then exit (3s confirm); steam_game_running() scans same-uid /proc for Steam's launch reaper matching both the `SteamLaunch` and `AppId=` argv tokens (exact-match; reaper lifetime == game lifetime, so shader precompile can't false-trigger). The host spawns a pf1-gamewatch thread for nested Steam launches that closes the connection with APP_EXITED (launcher clients return to their library) and sets quit/stop; cancelled via stop if the session ends first. Non-Steam nested launches keep the node-death path (gamescope's child IS the game). Flags: auto-enable `--steam` whenever the launch is a Steam launch (was only the global PUNKTFUNK_GAMESCOPE_STEAM knob, default-off) — in-game overlay / Steam+X / gamepad-UI nav for Steam titles with no operator config. New opt-in PUNKTFUNK_GAMESCOPE_GRAB_CURSOR adds `--force-grab-cursor` for a real game launch (FPS mouselook); default OFF because it forces relative mode, which breaks absolute-pointer games/menus. Verified: fmt clean; clippy -D warnings clean on the three crates; pf-vdisplay 64/0 (incl. new steam-appid parse test); punktfunk-host builds + 186/0; reaper /proc detection smoke-tested (detect, exact non-match, gone-after-exit). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/pf-host-config/src/lib.rs | 18 ++- crates/pf-vdisplay/src/lib.rs | 2 +- .../src/vdisplay/linux/gamescope.rs | 36 ++++- .../src/vdisplay/linux/gamescope/discovery.rs | 140 +++++++++++++++++- crates/pf-vdisplay/src/vdisplay/routing.rs | 22 +++ crates/punktfunk-host/src/native/stream.rs | 39 +++++ 6 files changed, 246 insertions(+), 11 deletions(-) diff --git a/crates/pf-host-config/src/lib.rs b/crates/pf-host-config/src/lib.rs index 9867509d..96e9c1ef 100644 --- a/crates/pf-host-config/src/lib.rs +++ b/crates/pf-host-config/src/lib.rs @@ -76,10 +76,16 @@ pub struct HostConfig { /// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the /// shipped `host.env` and as a forward seam if a second backend is ever added. pub vdisplay: Option, - /// `PUNKTFUNK_GAMESCOPE_STEAM` — opt the bare headless gamescope spawn into its Steam - /// integration mode (`--steam`). Managed gamescope-session-plus/SteamOS sessions own their - /// own flags and do not consult this. + /// `PUNKTFUNK_GAMESCOPE_STEAM` — force the bare headless gamescope spawn into its Steam + /// integration mode (`--steam`) for EVERY launch. A Steam title auto-enables `--steam` on its + /// own regardless of this knob; it exists to force it on for non-Steam launches too. Managed + /// gamescope-session-plus/SteamOS sessions own their own flags and do not consult this. pub gamescope_steam: bool, + /// `PUNKTFUNK_GAMESCOPE_GRAB_CURSOR` — add `--force-grab-cursor` to the bare headless gamescope + /// spawn for an actual game launch, forcing relative-mouse capture so FPS mouselook works over the + /// injected pointer. Default OFF: it forces relative mode, which breaks absolute-pointer titles + /// and menus, so it's opt-in per host until validated on-glass. + pub gamescope_grab_cursor: bool, /// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO /// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell /// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up @@ -152,6 +158,12 @@ impl HostConfig { "1" | "true" | "yes" | "on" ) }), + gamescope_grab_cursor: val("PUNKTFUNK_GAMESCOPE_GRAB_CURSOR").is_some_and(|s| { + matches!( + s.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }), recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD") .filter(|s| !s.trim().is_empty()), on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()), diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index acfa3698..2bfc79b4 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -78,7 +78,7 @@ pub use routing::{ #[cfg(target_os = "linux")] pub use routing::{ cancel_pending_tv_restore, dedicated_game_exited, launch_into_gamescope_session, - launch_is_nested, + launch_is_nested, steam_appid_from_launch, watch_steam_game_exit, }; /// Compositors punktfunk knows how to drive (plan §6). diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index f1ca1a31..e858c5ff 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -25,7 +25,10 @@ use discovery::{ check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, gamescope_node_present, poll_managed_node, wait_for_node, }; -pub(crate) use discovery::{game_session_exited, is_available}; +pub(crate) use discovery::{ + game_session_exited, is_available, steam_appid_from_launch, wait_for_steam_game_exit, + SteamGameWatch, +}; /// The gamescope virtual-display driver. Three modes by env, in precedence order: /// * `PUNKTFUNK_GAMESCOPE_SESSION=` — host-MANAGE a `gamescope-session-plus` session @@ -1369,7 +1372,14 @@ fn shape_dedicated_command(app: &str) -> String { /// Add the compositor-side arguments shared by every bare gamescope spawn. `steam_mode` belongs /// before the `--` terminator; [`PUNKTFUNK_GAMESCOPE_APP`](spawn) configures the nested command /// after it and therefore cannot enable gamescope's Steam integration itself. -fn add_bare_gamescope_args(command: &mut Command, w: u32, h: u32, hz: u32, steam_mode: bool) { +fn add_bare_gamescope_args( + command: &mut Command, + w: u32, + h: u32, + hz: u32, + steam_mode: bool, + grab_cursor: bool, +) { command .args(["--backend", "headless"]) .args(["-W", &w.to_string()]) @@ -1378,6 +1388,9 @@ fn add_bare_gamescope_args(command: &mut Command, w: u32, h: u32, hz: u32, steam if steam_mode { command.arg("--steam"); } + if grab_cursor { + command.arg("--force-grab-cursor"); + } command.args(["--xwayland-count", "1", "--"]); } @@ -1398,16 +1411,27 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R // Read the env fallback under the shared env lock so it can't race a concurrent session's // `set_var` of the same key (security-review 2026-06-28 #7). .or_else(|| crate::with_env_lock(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok())) - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| "sleep infinity".to_string()); + .filter(|s| !s.trim().is_empty()); + // A real app was requested (vs. the `sleep infinity` keep-alive) — used to scope the game-only + // cursor-grab flag below. + let game_launch = app.is_some(); + let app = app.unwrap_or_else(|| "sleep infinity".to_string()); // Dedicated-launch command shaping (Part B): a Steam URI runs with `-silent` so the game is the // gamescope focus with no Steam client window to navigate. let app = shape_dedicated_command(&app); let relay = ei_socket_file(); let _ = std::fs::remove_file(&relay); // stale socket path from a previous session - let steam_mode = pf_host_config::config().gamescope_steam; + // Enable gamescope's Steam integration (`--steam`: in-game overlay, Steam+X shortcuts, gamepad-UI + // navigation) whenever we're launching Steam — the operator no longer has to set the global + // PUNKTFUNK_GAMESCOPE_STEAM knob for a Steam title. The knob still forces it on for every spawn. + let steam_mode = pf_host_config::config().gamescope_steam || is_steam_launch(&app); + // Opt-in relative-mouse capture for a nested game (`PUNKTFUNK_GAMESCOPE_GRAB_CURSOR`): the client + // already sends relative motion, but gamescope only enters relative mode when the app hides the + // cursor, which some FPS titles never signal over the injected pointer — grabbing fixes mouselook. + // Default OFF (it forces relative mode, which would break absolute-pointer games/menus). + let grab_cursor = game_launch && pf_host_config::config().gamescope_grab_cursor; let mut cmd = Command::new("gamescope"); - add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode); + add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor); cmd.args([ "sh", "-c", diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs index 9831d121..dec69cd3 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs @@ -29,6 +29,122 @@ pub(crate) fn game_session_exited(node_id: u32) -> bool { } } +/// Outcome of watching a dedicated Steam launch's game lifetime ([`wait_for_steam_game_exit`]). +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum SteamGameWatch { + /// The game was seen running and has now exited — the session should end (APP_EXITED). + Exited, + /// The watch was cancelled (the session ended for another reason) or the game never started + /// within the startup grace — leave the session as-is. + Cancelled, +} + +/// Parse the Steam appid a dedicated launch targets from its resolved command +/// (`steam [-silent] steam://rungameid/`). `None` unless the first token is `steam` and a +/// `steam://rungameid/` URI is present — the trailing digits are the appid, which is exactly +/// what Steam's launch reaper carries as `AppId=` (gameid == appid for a plain library title, +/// the only kind the host ever resolves to this shape). +pub(crate) fn steam_appid_from_launch(cmd: &str) -> Option { + if cmd.split_whitespace().next() != Some("steam") { + return None; + } + const MARKER: &str = "steam://rungameid/"; + let tail = &cmd[cmd.find(MARKER)? + MARKER.len()..]; + let digits: String = tail + .chars() + .take_while(|c: &char| c.is_ascii_digit()) + .collect(); + digits.parse().ok() +} + +/// Block until the dedicated Steam game `appid` has started and then exited, `cancel` is set, or the +/// game never appears within the startup grace. Same-uid `/proc` scan keyed on Steam's launch reaper +/// (`SteamLaunch AppId=`), whose lifetime is exactly the game's. Returns +/// [`SteamGameWatch::Exited`] only after the game was actually seen running and then stayed gone +/// across a short confirmation window — so a cold Steam boot / shader precompile (game not up yet) or +/// a transient scan miss can't end the stream early. Runs on the host's per-session watch thread. +pub(crate) fn wait_for_steam_game_exit( + appid: u32, + cancel: &std::sync::atomic::AtomicBool, +) -> SteamGameWatch { + use std::sync::atomic::Ordering; + // Cold Steam boot + first-launch shader precompile can delay the game window by minutes; give it a + // generous window to appear. A game that never starts leaves the session up (the Steam client is + // still streamed, and the node-death path still covers the Steam client itself dying). + const START_GRACE: Duration = Duration::from_secs(300); + const POLL: Duration = Duration::from_secs(1); + // Require the reaper gone across this window (a few polls) so a brief process swap can't fire early. + const EXIT_CONFIRM: Duration = Duration::from_secs(3); + + let start_deadline = Instant::now() + START_GRACE; + // Phase 1: wait for the game's reaper to appear. + while !steam_game_running(appid) { + if cancel.load(Ordering::Relaxed) || Instant::now() >= start_deadline { + return SteamGameWatch::Cancelled; + } + std::thread::sleep(POLL); + } + // Phase 2: the game is up — wait for its reaper to disappear (confirmed across the window). + let mut gone_since: Option = None; + loop { + if cancel.load(Ordering::Relaxed) { + return SteamGameWatch::Cancelled; + } + if steam_game_running(appid) { + gone_since = None; + } else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM { + return SteamGameWatch::Exited; + } + std::thread::sleep(POLL); + } +} + +/// Is Steam's launch reaper for appid `appid` alive right now (same uid as the host)? Steam wraps +/// every game launch — native or Proton — in `…/reaper SteamLaunch AppId= -- `, and the +/// reaper lives for the game's whole lifetime, so its presence is a precise "the game is running" +/// signal. Matched on the `SteamLaunch` + `AppId=` argv tokens together (exact-match, so +/// `AppId=57` never matches appid 570) — specific to the game reaper, so Steam's own shader-precompile +/// step (not reaper-wrapped) can't be mistaken for the game. +fn steam_game_running(appid: u32) -> bool { + // SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory. + let uid = unsafe { libc::getuid() }; + let appid_tok = format!("AppId={appid}"); + 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; + }; + 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(cmdline) = std::fs::read(e.path().join("cmdline")) else { + continue; + }; + let (mut launch, mut appid_match) = (false, false); + for arg in cmdline.split(|&b| b == 0) { + if arg == b"SteamLaunch" { + launch = true; + } else if arg == appid_tok.as_bytes() { + appid_match = true; + } + } + if launch && appid_match { + return true; + } + } + false +} + /// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which /// logs to journald (no per-spawn file) and is single-session (no scoping needed). pub(super) fn poll_managed_node(timeout: Duration) -> Option { @@ -263,7 +379,29 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> { #[cfg(test)] mod tests { - use super::{parse_version, MIN_GAMESCOPE}; + use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE}; + + #[test] + fn parses_steam_appid_from_launch() { + // The resolved dedicated-launch command (pre- or post-`-silent` shaping) → the appid. + assert_eq!( + steam_appid_from_launch("steam steam://rungameid/570"), + Some(570) + ); + assert_eq!( + steam_appid_from_launch("steam -silent steam://rungameid/1091500"), + Some(1091500) + ); + // Non-Steam launches / bare Steam with no rungameid URI → no appid (no game-exit watch). + assert_eq!(steam_appid_from_launch("lutris lutris:rungameid/42"), None); + assert_eq!(steam_appid_from_launch("steam -gamepadui"), None); + assert_eq!(steam_appid_from_launch("vkcube"), None); + // A steam:// URI that isn't the first `steam` token (a custom command) is not treated as one. + assert_eq!( + steam_appid_from_launch("firefox steam://rungameid/570"), + None + ); + } #[test] fn parses_version_banner() { diff --git a/crates/pf-vdisplay/src/vdisplay/routing.rs b/crates/pf-vdisplay/src/vdisplay/routing.rs index 09d8acd4..06968c07 100644 --- a/crates/pf-vdisplay/src/vdisplay/routing.rs +++ b/crates/pf-vdisplay/src/vdisplay/routing.rs @@ -176,6 +176,28 @@ pub fn dedicated_game_exited(_node_id: u32) -> bool { false } +/// The Steam appid a dedicated launch targets (`steam … steam://rungameid/`), for the +/// game-exit watcher. `None` for a non-Steam launch (those are covered by the node-death path +/// [`dedicated_game_exited`] — gamescope's nested child IS the game). See +/// [`gamescope::steam_appid_from_launch`]. +#[cfg(target_os = "linux")] +pub fn steam_appid_from_launch(cmd: &str) -> Option { + gamescope::steam_appid_from_launch(cmd) +} + +/// Block until the dedicated Steam game `appid` has started and then exited — returns `true` when the +/// session should end cleanly (APP_EXITED). Returns `false` if `cancel` is set (the session ended for +/// another reason) or the game never started within the startup grace (leave the session up). Runs on +/// the host's per-session watch thread; `cancel` is the session's stop flag. See +/// [`gamescope::wait_for_steam_game_exit`]. +#[cfg(target_os = "linux")] +pub fn watch_steam_game_exit(appid: u32, cancel: &std::sync::atomic::AtomicBool) -> bool { + matches!( + gamescope::wait_for_steam_game_exit(appid, cancel), + gamescope::SteamGameWatch::Exited + ) +} + /// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux. #[cfg(target_os = "linux")] pub fn cancel_pending_tv_restore() { diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index a2eda7ac..8da1117b 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -978,6 +978,45 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option