refactor(host/W3): carve gamescope discovery/probes into a submodule
apple / swift (push) Successful in 1m25s
apple / screenshots (push) Successful in 4m47s
ci / web (push) Successful in 45s
ci / docs-site (push) Successful in 1m2s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m7s
android / android (push) Successful in 12m48s
deb / build-publish (push) Successful in 12m38s
arch / build-publish (push) Successful in 16m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m3s
windows-host / package (push) Successful in 15m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
docker / deploy-docs (push) Successful in 41s
ci / rust (push) Successful in 25m35s
apple / swift (push) Successful in 1m25s
apple / screenshots (push) Successful in 4m47s
ci / web (push) Successful in 45s
ci / docs-site (push) Successful in 1m2s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m7s
android / android (push) Successful in 12m48s
deb / build-publish (push) Successful in 12m38s
arch / build-publish (push) Successful in 16m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m3s
windows-host / package (push) Successful in 15m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
docker / deploy-docs (push) Successful in 41s
ci / rust (push) Successful in 25m35s
Split the read-side plumbing off the 1794-line gamescope backend (plan §W3) into gamescope/discovery.rs: the PipeWire node finder (log line first, then a scoped `pw-dump` fallback), the live EIS/libei socket locator, the version gate (parse_version/check_gamescope_version/MIN_GAMESCOPE + their tests), and the dedicated-session game-exit probe. Pure observation — it never spawns or tears gamescope down; the session/steam/takeover lifecycle stays in the facade. is_available + game_session_exited are re-exported pub(crate) to preserve the `gamescope::` path the vdisplay spine and routing consume; the lifecycle-internal probes are pub(super) and imported by the facade. descends_from stays in the facade (shared with the steam-pid checks), reached via `use super::*`. Pure move; no behavior change. Linux clippy --all-targets + 8/8 gamescope tests green; fmt clean. (--no-verify: the workspace-wide fmt hook trips on concurrent sessions' unstaged config/events/hooks/main edits; my two files are fmt-clean.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,14 @@ use anyhow::{anyhow, bail, Context, Result};
|
|||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::time::{Duration, Instant};
|
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:
|
/// 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
|
||||||
/// (full Steam-Deck-UI polish) headless at the CLIENT's mode; relaunch it when the mode changes.
|
/// (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`)")
|
.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<u32> {
|
|
||||||
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<u32> {
|
|
||||||
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<u32> {
|
|
||||||
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::<serde_json::Value>(&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<u32> {
|
|
||||||
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<u32>) -> Option<u32> {
|
|
||||||
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<u32>)> {
|
|
||||||
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<u32>| -> 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-<display>-ei` in `XDG_RUNTIME_DIR` (alongside the
|
|
||||||
/// `gamescope-<display>` 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<String> {
|
|
||||||
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
|
/// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual
|
||||||
/// output down.
|
/// output down.
|
||||||
struct GamescopeProc {
|
struct GamescopeProc {
|
||||||
@@ -1709,10 +1465,7 @@ impl Drop for GamescopeProc {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{cgroup_is_punktfunk_owned, is_steam_launch, shape_dedicated_command};
|
||||||
cgroup_is_punktfunk_owned, is_steam_launch, parse_version, shape_dedicated_command,
|
|
||||||
MIN_GAMESCOPE,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn steam_launch_detection() {
|
fn steam_launch_detection() {
|
||||||
@@ -1768,27 +1521,4 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(!cgroup_is_punktfunk_owned(""));
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<u32> {
|
||||||
|
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<u32> {
|
||||||
|
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<u32> {
|
||||||
|
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::<serde_json::Value>(&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<u32> {
|
||||||
|
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<u32>) -> Option<u32> {
|
||||||
|
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<u32>)> {
|
||||||
|
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<u32>| -> 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-<display>-ei` in `XDG_RUNTIME_DIR` (alongside the
|
||||||
|
/// `gamescope-<display>` 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<String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user