feat(gamescope): end dedicated stream on Steam game exit + auto --steam
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m12s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / bench (push) Successful in 6m57s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m51s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
apple / screenshots (push) Successful in 6m13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
deb / build-publish (push) Successful in 9m37s
docker / deploy-docs (push) Successful in 29s
deb / build-publish-host (push) Successful in 9m29s
windows-host / package (push) Successful in 16m24s
arch / build-publish (push) Successful in 17m40s
android / android (push) Successful in 18m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m52s
ci / rust (push) Successful in 26m16s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m12s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / bench (push) Successful in 6m57s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m51s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
apple / screenshots (push) Successful in 6m13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
deb / build-publish (push) Successful in 9m37s
docker / deploy-docs (push) Successful in 29s
deb / build-publish-host (push) Successful in 9m29s
windows-host / package (push) Successful in 16m24s
arch / build-publish (push) Successful in 17m40s
android / android (push) Successful in 18m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m52s
ci / rust (push) Successful in 26m16s
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/<id>; 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=<id>` 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) <noreply@anthropic.com>
This commit is contained in:
@@ -76,10 +76,16 @@ pub struct HostConfig {
|
|||||||
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
|
/// 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.
|
/// shipped `host.env` and as a forward seam if a second backend is ever added.
|
||||||
pub vdisplay: Option<String>,
|
pub vdisplay: Option<String>,
|
||||||
/// `PUNKTFUNK_GAMESCOPE_STEAM` — opt the bare headless gamescope spawn into its Steam
|
/// `PUNKTFUNK_GAMESCOPE_STEAM` — force the bare headless gamescope spawn into its Steam
|
||||||
/// integration mode (`--steam`). Managed gamescope-session-plus/SteamOS sessions own their
|
/// integration mode (`--steam`) for EVERY launch. A Steam title auto-enables `--steam` on its
|
||||||
/// own flags and do not consult this.
|
/// 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,
|
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
|
/// `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
|
/// 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
|
/// 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"
|
"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")
|
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||||
.filter(|s| !s.trim().is_empty()),
|
.filter(|s| !s.trim().is_empty()),
|
||||||
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ pub use routing::{
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use routing::{
|
pub use routing::{
|
||||||
cancel_pending_tv_restore, dedicated_game_exited, launch_into_gamescope_session,
|
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).
|
/// Compositors punktfunk knows how to drive (plan §6).
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ use discovery::{
|
|||||||
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
|
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
|
||||||
gamescope_node_present, poll_managed_node, wait_for_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:
|
/// The gamescope virtual-display driver. Three modes by env, in precedence order:
|
||||||
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session
|
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — 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
|
/// 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
|
/// before the `--` terminator; [`PUNKTFUNK_GAMESCOPE_APP`](spawn) configures the nested command
|
||||||
/// after it and therefore cannot enable gamescope's Steam integration itself.
|
/// 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
|
command
|
||||||
.args(["--backend", "headless"])
|
.args(["--backend", "headless"])
|
||||||
.args(["-W", &w.to_string()])
|
.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 {
|
if steam_mode {
|
||||||
command.arg("--steam");
|
command.arg("--steam");
|
||||||
}
|
}
|
||||||
|
if grab_cursor {
|
||||||
|
command.arg("--force-grab-cursor");
|
||||||
|
}
|
||||||
command.args(["--xwayland-count", "1", "--"]);
|
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
|
// 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).
|
// `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()))
|
.or_else(|| crate::with_env_lock(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok()))
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty());
|
||||||
.unwrap_or_else(|| "sleep infinity".to_string());
|
// 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
|
// 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.
|
// gamescope focus with no Steam client window to navigate.
|
||||||
let app = shape_dedicated_command(&app);
|
let app = shape_dedicated_command(&app);
|
||||||
let relay = ei_socket_file();
|
let relay = ei_socket_file();
|
||||||
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
|
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");
|
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([
|
cmd.args([
|
||||||
"sh",
|
"sh",
|
||||||
"-c",
|
"-c",
|
||||||
|
|||||||
@@ -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/<appid>`). `None` unless the first token is `steam` and a
|
||||||
|
/// `steam://rungameid/<digits>` URI is present — the trailing digits are the appid, which is exactly
|
||||||
|
/// what Steam's launch reaper carries as `AppId=<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<u32> {
|
||||||
|
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=<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<Instant> = 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=<appid> -- <game>`, 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=<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
|
/// 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).
|
/// logs to journald (no per-spawn file) and is single-session (no scoping needed).
|
||||||
pub(super) fn poll_managed_node(timeout: Duration) -> Option<u32> {
|
pub(super) fn poll_managed_node(timeout: Duration) -> Option<u32> {
|
||||||
@@ -263,7 +379,29 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn parses_version_banner() {
|
fn parses_version_banner() {
|
||||||
|
|||||||
@@ -176,6 +176,28 @@ pub fn dedicated_game_exited(_node_id: u32) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Steam appid a dedicated launch targets (`steam … steam://rungameid/<appid>`), 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<u32> {
|
||||||
|
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.
|
/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn cancel_pending_tv_restore() {
|
pub fn cancel_pending_tv_restore() {
|
||||||
|
|||||||
@@ -978,6 +978,45 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||||
let _ = &launch;
|
let _ = &launch;
|
||||||
|
|
||||||
|
// Dedicated Steam launch: end the stream cleanly when the LAUNCHED GAME exits. The node-death
|
||||||
|
// check in the capture-loss branch below can't see this for Steam — the nested `steam` is the
|
||||||
|
// resident client and stays up after a game quits, so gamescope (and its node) never dies and the
|
||||||
|
// stream would sit on a hidden Steam session forever. Watch the game process directly (Steam's
|
||||||
|
// `SteamLaunch AppId=<id>` reaper, whose lifetime == the game's) and close with APP_EXITED when
|
||||||
|
// it's gone, so a launcher client returns to its library. Non-Steam nested launches keep the
|
||||||
|
// node-death path (gamescope's child IS the game). Cancelled via `stop` when the session ends for
|
||||||
|
// another reason first; the thread self-terminates, so we don't join it.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let _game_watch = launch
|
||||||
|
.as_deref()
|
||||||
|
.filter(|_| crate::vdisplay::launch_is_nested(compositor))
|
||||||
|
.and_then(crate::vdisplay::steam_appid_from_launch)
|
||||||
|
.map(|appid| {
|
||||||
|
let conn = conn.clone();
|
||||||
|
let stop = stop.clone();
|
||||||
|
let quit = quit.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pf1-gamewatch".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if crate::vdisplay::watch_steam_game_exit(appid, &stop) {
|
||||||
|
tracing::info!(
|
||||||
|
appid,
|
||||||
|
"dedicated Steam game exited — ending the session cleanly (APP_EXITED)"
|
||||||
|
);
|
||||||
|
// Close FIRST so APP_EXITED is the winning close code (quinn keeps the first
|
||||||
|
// application close), then set the flags: `quit` skips the display lease's
|
||||||
|
// keep-alive linger and `stop` wakes the encode/send loops out.
|
||||||
|
conn.close(
|
||||||
|
punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(),
|
||||||
|
b"game exited",
|
||||||
|
);
|
||||||
|
quit.store(true, Ordering::SeqCst);
|
||||||
|
stop.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
|
||||||
let perf = pf_host_config::config().perf;
|
let perf = pf_host_config::config().perf;
|
||||||
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ the cap bursts out
|
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ the cap bursts out
|
||||||
// immediately; only a bigger frame's overflow is spread. `None` = auto — max(128 KB, the
|
// immediately; only a bigger frame's overflow is spread. `None` = auto — max(128 KB, the
|
||||||
|
|||||||
Reference in New Issue
Block a user