diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs index 1decebba..4268604e 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs @@ -19,6 +19,14 @@ use anyhow::{anyhow, bail, Context, Result}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; +#[path = "gamescope/discovery.rs"] +mod discovery; +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}; + /// The gamescope virtual-display driver. Three modes by env, in precedence order: /// * `PUNKTFUNK_GAMESCOPE_SESSION=` — host-MANAGE a `gamescope-session-plus` session /// (full Steam-Deck-UI polish) headless at the CLIENT's mode; relaunch it when the mode changes. @@ -1436,258 +1444,6 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R .context("spawn gamescope (is it installed? `apt install gamescope`)") } -/// Wait for gamescope to report its PipeWire node. Authoritative source: gamescope's own log -/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects -/// — the adapter and the inner stream — and only the advertised id is the correct capture -/// target). Falls back to `pw-dump` discovery if the log line doesn't show. -/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is -/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS -/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a -/// transient PipeWire hiccup). Scoped to the session's own `node_id` (via [`gamescope_node_present`]), -/// so a **coexisting** gamescope (a second dedicated session, or the box's game-mode gamescope beside a -/// non-Steam dedicated launch) doesn't mask the exit (review findings #4/#8). Returns `true` when the -/// node stays absent across the window. -pub fn game_session_exited(node_id: u32) -> bool { - let deadline = Instant::now() + Duration::from_millis(1500); - loop { - if gamescope_node_present(node_id) { - return false; // OUR node is (still) present → not an exit (transient loss) - } - if Instant::now() >= deadline { - return true; // our node stayed gone across the window → the game exited - } - std::thread::sleep(Duration::from_millis(250)); - } -} - -/// 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). -fn poll_managed_node(timeout: Duration) -> Option { - let deadline = Instant::now() + timeout; - loop { - if let Some(id) = find_gamescope_node() { - return Some(id); - } - if Instant::now() >= deadline { - return None; - } - std::thread::sleep(Duration::from_millis(300)); - } -} - -fn wait_for_node(timeout: Duration, log: &std::path::Path, child_pid: u32) -> Option { - let deadline = Instant::now() + timeout; - loop { - if let Some(id) = node_from_log(log) { - return Some(id); - } - if Instant::now() >= deadline { - // Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's - // node isn't picked by mistake. - return find_gamescope_node_scoped(Some(child_pid)); - } - std::thread::sleep(Duration::from_millis(300)); - } -} - -/// Parse `stream available on node ID: N` from a spawned gamescope's per-instance log (ANSI-colored). -fn node_from_log(log: &std::path::Path) -> Option { - let log = std::fs::read_to_string(log).ok()?; - for line in log.lines().rev() { - if let Some(pos) = line.find("stream available on node ID:") { - let tail = &line[pos + "stream available on node ID:".len()..]; - let digits: String = tail.chars().filter(|c| c.is_ascii_digit()).collect(); - if let Ok(id) = digits.parse() { - return Some(id); - } - } - } - None -} - -/// Is a PipeWire node with exactly `node_id` present on the default daemon right now? Used by the -/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node -/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse". -fn gamescope_node_present(node_id: u32) -> bool { - let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else { - // pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node). - return true; - }; - let Ok(dump) = serde_json::from_slice::(&out.stdout) else { - return true; - }; - dump.as_array() - .map(|objs| { - objs.iter().any(|o| { - o.get("id").and_then(|i| i.as_u64()) == Some(node_id as u64) - && o.get("type").and_then(|t| t.as_str()) == Some("PipeWire:Interface:Node") - }) - }) - .unwrap_or(true) -} - -/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon. -/// -/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only -/// the one whose `media.class` is `Video/Source` is a valid capture target — connecting to the -/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match -/// only if no class-tagged node is present (older gamescope that doesn't set media.class). -fn find_gamescope_node() -> Option { - find_gamescope_node_scoped(None) -} - -/// Like [`find_gamescope_node`], but when `scope` is `Some(pid)` only a node whose owning process -/// (`application.process.id`) is `pid` or a descendant of it qualifies (A5 — a spawn's node must -/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is -/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session). -fn find_gamescope_node_scoped(scope: Option) -> Option { - let out = Command::new("pw-dump").output().ok()?; - let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; - let nodes = dump.as_array()?; - let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option)> { - if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") { - return None; - } - let id = obj.get("id").and_then(|i| i.as_u64())? as u32; - let props = obj.get("info").and_then(|i| i.get("props")); - let name = props - .and_then(|p| p.get("node.name")) - .and_then(|n| n.as_str()) - .unwrap_or("") - .to_string(); - let class = props - .and_then(|p| p.get("media.class")) - .and_then(|n| n.as_str()) - .unwrap_or("") - .to_string(); - // PipeWire records the owning process id as a string or an int depending on version. - let pid = props - .and_then(|p| p.get("application.process.id")) - .and_then(|v| { - v.as_u64() - .or_else(|| v.as_str().and_then(|s| s.parse().ok())) - .map(|n| n as u32) - }); - Some((id, name, class, pid)) - }; - // A node is in-scope when no scope is asked, or its owning pid descends from the scope pid. When - // the pid prop is absent (older gamescope / PipeWire) we DON'T exclude it — falling back to the - // per-instance log is the primary addressing (design §7 risk note). - let in_scope = |pid: Option| -> bool { - match scope { - None => true, - Some(root) => pid.map(|p| descends_from(p, root)).unwrap_or(true), - } - }; - // Preferred: a Video/Source node named (or containing) "gamescope", in scope. - for obj in nodes { - if let Some((id, name, class, pid)) = node_props(obj) { - if class == "Video/Source" - && (name == "gamescope" || name.contains("gamescope")) - && in_scope(pid) - { - return Some(id); - } - } - } - // Fallback: a node literally named "gamescope" with no usable class tag, in scope. - for obj in nodes { - if let Some((id, name, _, pid)) = node_props(obj) { - if name == "gamescope" && in_scope(pid) { - tracing::warn!( - node_id = id, - "gamescope node has no media.class=Video/Source tag — capturing it anyway" - ); - return Some(id); - } - } - } - None -} - -/// Find the live gamescope EIS (libei) socket to inject into when ATTACHING to an existing -/// session (the spawn path instead relays the nested gamescope's `LIBEI_SOCKET` through a file). -/// -/// gamescope names its EIS socket `gamescope--ei` in `XDG_RUNTIME_DIR` (alongside the -/// `gamescope-` wayland socket). Stale sockets from dead sessions linger, so we don't -/// trust the name — we `connect()` each candidate and keep the connectable ones, returning the -/// most recently created (the live session). Returns the bare socket *name* (the injector -/// resolves it against `XDG_RUNTIME_DIR`, matching libei's own `LIBEI_SOCKET` semantics). -fn find_gamescope_eis_socket() -> Option { - let runtime = std::env::var("XDG_RUNTIME_DIR").ok()?; - let mut live: Vec<(std::time::SystemTime, String)> = Vec::new(); - for entry in std::fs::read_dir(&runtime).ok()?.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - // The EIS socket itself, not its `.lock` sidecar or the bare wayland socket. - if !(name.starts_with("gamescope-") && name.ends_with("-ei")) { - continue; - } - // Connectable == a live listener is behind it (a dead session's socket refuses). - if std::os::unix::net::UnixStream::connect(entry.path()).is_err() { - continue; - } - let mtime = entry - .metadata() - .and_then(|m| m.modified()) - .unwrap_or(std::time::UNIX_EPOCH); - live.push((mtime, name)); - } - live.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); // newest first - live.into_iter().next().map(|(_, n)| n) -} - -/// gamescope is usable wherever its binary runs — it spawns its own nested session, so it does -/// not require any particular desktop to be running. Quiet (no version warning — that's for the -/// create path); just checks the binary executes. -pub fn is_available() -> bool { - std::process::Command::new("gamescope") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} - -/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks -/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon. -const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22); - -/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing -/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a -/// gate. Returns the parsed version when it could read one. -fn check_gamescope_version() -> Option<(u32, u32, u32)> { - let out = Command::new("gamescope").arg("--version").output().ok()?; - // gamescope prints the version banner to stderr on some builds, stdout on others. - let text = format!( - "{}{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - let ver = parse_version(&text)?; - if ver < MIN_GAMESCOPE { - tracing::warn!( - found = %format!("{}.{}.{}", ver.0, ver.1, ver.2), - min = %format!("{}.{}.{}", MIN_GAMESCOPE.0, MIN_GAMESCOPE.1, MIN_GAMESCOPE.2), - "gamescope is older than the minimum for reliable headless capture — expect a \ - capture deadlock against PipeWire ≥ 1.6 (a wedged link head-blocks the daemon); \ - upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter" - ); - } - Some(ver) -} - -/// Extract the first `X.Y.Z` version triple from arbitrary text (e.g. `gamescope version 3.16.22`). -fn parse_version(text: &str) -> Option<(u32, u32, u32)> { - for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) { - let mut parts = token.split('.'); - let (a, b, c) = (parts.next()?, parts.next(), parts.next()); - let (Some(b), Some(c)) = (b, c) else { continue }; - if let (Ok(a), Ok(b), Ok(c)) = (a.parse(), b.parse(), c.parse()) { - return Some((a, b, c)); - } - } - None -} - /// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual /// output down. struct GamescopeProc { @@ -1709,10 +1465,7 @@ impl Drop for GamescopeProc { #[cfg(test)] mod tests { - use super::{ - cgroup_is_punktfunk_owned, is_steam_launch, parse_version, shape_dedicated_command, - MIN_GAMESCOPE, - }; + use super::{cgroup_is_punktfunk_owned, is_steam_launch, shape_dedicated_command}; #[test] fn steam_launch_detection() { @@ -1768,27 +1521,4 @@ mod tests { )); assert!(!cgroup_is_punktfunk_owned("")); } - - #[test] - fn parses_version_banner() { - assert_eq!( - parse_version("gamescope version 3.16.22"), - Some((3, 16, 22)) - ); - assert_eq!( - parse_version("gamescope: version v3.15.9 (no PipeWire)"), - Some((3, 15, 9)) - ); - assert_eq!(parse_version("3.16.20-1.fc41"), Some((3, 16, 20))); - assert_eq!(parse_version("no version here"), None); - assert_eq!(parse_version("only 3.16 here"), None); // needs a full triple - } - - #[test] - fn flags_known_bad_versions() { - // The 26.04-shipped 3.16.20 is below the minimum (PipeWire 1.6 deadlock). - assert!(parse_version("gamescope version 3.16.20").unwrap() < MIN_GAMESCOPE); - assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE); - assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE); - } } diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope/discovery.rs b/crates/punktfunk-host/src/vdisplay/linux/gamescope/discovery.rs new file mode 100644 index 00000000..9831d121 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/linux/gamescope/discovery.rs @@ -0,0 +1,290 @@ +//! gamescope **discovery + probes** (plan §W3, carved out of the backend): finding the compositor's +//! PipeWire node (log line first, then a scoped `pw-dump` fallback), locating its live EIS/libei +//! socket, the version gate, and the dedicated-session game-exit check. Pure read-side plumbing — it +//! observes gamescope, never spawns or tears it down (that stays in [`super`]). + +use super::*; + +/// Wait for gamescope to report its PipeWire node. Authoritative source: gamescope's own log +/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects +/// — the adapter and the inner stream — and only the advertised id is the correct capture +/// target). Falls back to `pw-dump` discovery if the log line doesn't show. +/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is +/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS +/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a +/// transient PipeWire hiccup). Scoped to the session's own `node_id` (via [`gamescope_node_present`]), +/// so a **coexisting** gamescope (a second dedicated session, or the box's game-mode gamescope beside a +/// non-Steam dedicated launch) doesn't mask the exit (review findings #4/#8). Returns `true` when the +/// node stays absent across the window. +pub(crate) fn game_session_exited(node_id: u32) -> bool { + let deadline = Instant::now() + Duration::from_millis(1500); + loop { + if gamescope_node_present(node_id) { + return false; // OUR node is (still) present → not an exit (transient loss) + } + if Instant::now() >= deadline { + return true; // our node stayed gone across the window → the game exited + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +/// 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 { + let deadline = Instant::now() + timeout; + loop { + if let Some(id) = find_gamescope_node() { + return Some(id); + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(300)); + } +} + +pub(super) fn wait_for_node( + timeout: Duration, + log: &std::path::Path, + child_pid: u32, +) -> Option { + let deadline = Instant::now() + timeout; + loop { + if let Some(id) = node_from_log(log) { + return Some(id); + } + if Instant::now() >= deadline { + // Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's + // node isn't picked by mistake. + return find_gamescope_node_scoped(Some(child_pid)); + } + std::thread::sleep(Duration::from_millis(300)); + } +} + +/// Parse `stream available on node ID: N` from a spawned gamescope's per-instance log (ANSI-colored). +fn node_from_log(log: &std::path::Path) -> Option { + let log = std::fs::read_to_string(log).ok()?; + for line in log.lines().rev() { + if let Some(pos) = line.find("stream available on node ID:") { + let tail = &line[pos + "stream available on node ID:".len()..]; + let digits: String = tail.chars().filter(|c| c.is_ascii_digit()).collect(); + if let Ok(id) = digits.parse() { + return Some(id); + } + } + } + None +} + +/// Is a PipeWire node with exactly `node_id` present on the default daemon right now? Used by the +/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node +/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse". +pub(super) fn gamescope_node_present(node_id: u32) -> bool { + let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else { + // pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node). + return true; + }; + let Ok(dump) = serde_json::from_slice::(&out.stdout) else { + return true; + }; + dump.as_array() + .map(|objs| { + objs.iter().any(|o| { + o.get("id").and_then(|i| i.as_u64()) == Some(node_id as u64) + && o.get("type").and_then(|t| t.as_str()) == Some("PipeWire:Interface:Node") + }) + }) + .unwrap_or(true) +} + +/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon. +/// +/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only +/// the one whose `media.class` is `Video/Source` is a valid capture target — connecting to the +/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match +/// only if no class-tagged node is present (older gamescope that doesn't set media.class). +pub(super) fn find_gamescope_node() -> Option { + find_gamescope_node_scoped(None) +} + +/// Like [`find_gamescope_node`], but when `scope` is `Some(pid)` only a node whose owning process +/// (`application.process.id`) is `pid` or a descendant of it qualifies (A5 — a spawn's node must +/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is +/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session). +fn find_gamescope_node_scoped(scope: Option) -> Option { + let out = Command::new("pw-dump").output().ok()?; + let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; + let nodes = dump.as_array()?; + let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option)> { + if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") { + return None; + } + let id = obj.get("id").and_then(|i| i.as_u64())? as u32; + let props = obj.get("info").and_then(|i| i.get("props")); + let name = props + .and_then(|p| p.get("node.name")) + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + let class = props + .and_then(|p| p.get("media.class")) + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + // PipeWire records the owning process id as a string or an int depending on version. + let pid = props + .and_then(|p| p.get("application.process.id")) + .and_then(|v| { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + .map(|n| n as u32) + }); + Some((id, name, class, pid)) + }; + // A node is in-scope when no scope is asked, or its owning pid descends from the scope pid. When + // the pid prop is absent (older gamescope / PipeWire) we DON'T exclude it — falling back to the + // per-instance log is the primary addressing (design §7 risk note). + let in_scope = |pid: Option| -> bool { + match scope { + None => true, + Some(root) => pid.map(|p| descends_from(p, root)).unwrap_or(true), + } + }; + // Preferred: a Video/Source node named (or containing) "gamescope", in scope. + for obj in nodes { + if let Some((id, name, class, pid)) = node_props(obj) { + if class == "Video/Source" + && (name == "gamescope" || name.contains("gamescope")) + && in_scope(pid) + { + return Some(id); + } + } + } + // Fallback: a node literally named "gamescope" with no usable class tag, in scope. + for obj in nodes { + if let Some((id, name, _, pid)) = node_props(obj) { + if name == "gamescope" && in_scope(pid) { + tracing::warn!( + node_id = id, + "gamescope node has no media.class=Video/Source tag — capturing it anyway" + ); + return Some(id); + } + } + } + None +} + +/// Find the live gamescope EIS (libei) socket to inject into when ATTACHING to an existing +/// session (the spawn path instead relays the nested gamescope's `LIBEI_SOCKET` through a file). +/// +/// gamescope names its EIS socket `gamescope--ei` in `XDG_RUNTIME_DIR` (alongside the +/// `gamescope-` wayland socket). Stale sockets from dead sessions linger, so we don't +/// trust the name — we `connect()` each candidate and keep the connectable ones, returning the +/// most recently created (the live session). Returns the bare socket *name* (the injector +/// resolves it against `XDG_RUNTIME_DIR`, matching libei's own `LIBEI_SOCKET` semantics). +pub(super) fn find_gamescope_eis_socket() -> Option { + let runtime = std::env::var("XDG_RUNTIME_DIR").ok()?; + let mut live: Vec<(std::time::SystemTime, String)> = Vec::new(); + for entry in std::fs::read_dir(&runtime).ok()?.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + // The EIS socket itself, not its `.lock` sidecar or the bare wayland socket. + if !(name.starts_with("gamescope-") && name.ends_with("-ei")) { + continue; + } + // Connectable == a live listener is behind it (a dead session's socket refuses). + if std::os::unix::net::UnixStream::connect(entry.path()).is_err() { + continue; + } + let mtime = entry + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::UNIX_EPOCH); + live.push((mtime, name)); + } + live.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); // newest first + live.into_iter().next().map(|(_, n)| n) +} + +/// gamescope is usable wherever its binary runs — it spawns its own nested session, so it does +/// not require any particular desktop to be running. Quiet (no version warning — that's for the +/// create path); just checks the binary executes. +pub(crate) fn is_available() -> bool { + std::process::Command::new("gamescope") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks +/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon. +const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22); + +/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing +/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a +/// gate. Returns the parsed version when it could read one. +pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> { + let out = Command::new("gamescope").arg("--version").output().ok()?; + // gamescope prints the version banner to stderr on some builds, stdout on others. + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let ver = parse_version(&text)?; + if ver < MIN_GAMESCOPE { + tracing::warn!( + found = %format!("{}.{}.{}", ver.0, ver.1, ver.2), + min = %format!("{}.{}.{}", MIN_GAMESCOPE.0, MIN_GAMESCOPE.1, MIN_GAMESCOPE.2), + "gamescope is older than the minimum for reliable headless capture — expect a \ + capture deadlock against PipeWire ≥ 1.6 (a wedged link head-blocks the daemon); \ + upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter" + ); + } + Some(ver) +} + +/// Extract the first `X.Y.Z` version triple from arbitrary text (e.g. `gamescope version 3.16.22`). +fn parse_version(text: &str) -> Option<(u32, u32, u32)> { + for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) { + let mut parts = token.split('.'); + let (a, b, c) = (parts.next()?, parts.next(), parts.next()); + let (Some(b), Some(c)) = (b, c) else { continue }; + if let (Ok(a), Ok(b), Ok(c)) = (a.parse(), b.parse(), c.parse()) { + return Some((a, b, c)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::{parse_version, MIN_GAMESCOPE}; + + #[test] + fn parses_version_banner() { + assert_eq!( + parse_version("gamescope version 3.16.22"), + Some((3, 16, 22)) + ); + assert_eq!( + parse_version("gamescope: version v3.15.9 (no PipeWire)"), + Some((3, 15, 9)) + ); + assert_eq!(parse_version("3.16.20-1.fc41"), Some((3, 16, 20))); + assert_eq!(parse_version("no version here"), None); + assert_eq!(parse_version("only 3.16 here"), None); // needs a full triple + } + + #[test] + fn flags_known_bad_versions() { + // The 26.04-shipped 3.16.20 is below the minimum (PipeWire 1.6 deadlock). + assert!(parse_version("gamescope version 3.16.20").unwrap() < MIN_GAMESCOPE); + assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE); + assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE); + } +}