fix(host): make client game launches work on every Linux compositor

Client-initiated launches (Hello.launch / GameStream applist) were only
wired to gamescope's bare-spawn path via the process-global
PUNKTFUNK_GAMESCOPE_APP env — which leaked across sessions, was never
read by kwin/mutter/wlroots (launch was a silent no-op there), and was
unreachable on gamescope anyway because apply_input_env unconditionally
defaulted to the managed session (which bails on non-Bazzite/SteamOS
boxes and ignores the launch command in all its modes).

- Thread the launch per-session: resolve the library id at handshake,
  carry it on SessionContext (Windows: id; else: resolved command), and
  hand it to the backend instance via set_launch_command — the global
  env write is gone (the env stays as an operator fallback in spawn).
- Gamescope sub-mode ladder (pick_gamescope_mode, pure + unit-tested):
  managed only when session-plus/SteamOS infra exists, attach for an
  explicit request or a foreign (non-host-descendant) gamescope, else
  bare spawn — which nests the launch and is now reachable on plain
  distros instead of the guaranteed managed-mode bail.
- launch_session_command: one launch entry point for both planes once
  capture is live — desktop compositors plain-spawn into the retargeted
  session (the virtual output is primary); managed/attached gamescope
  spawns with the live session's DISPLAY/GAMESCOPE_WAYLAND_DISPLAY
  discovered from /proc (steam:// URIs also forward over Steam's own
  pipe). launch_is_nested gates bare spawn against double-launching.
- GameStream unified onto the same dispatch; also nests library-id
  picks into gamescope (previously only apps.json cmd was nested).

Validated live on the dev box up to the missing-GPU wall: handshake
resolution, Spawn sub-mode on plain Ubuntu, gamescope spawned with the
command nested. On-glass validation (kwin spawn on the streamed output,
Bazzite/Deck managed forward) pending GPU reattach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 22:02:52 +00:00
parent 7c976bc8c3
commit e7b07d2363
5 changed files with 443 additions and 120 deletions
@@ -233,6 +233,165 @@ fn steamos_session_present() -> bool {
&& !std::path::Path::new(SESSION_PLUS_BIN).exists()
}
/// Does this box have the infrastructure the MANAGED gamescope mode drives — Bazzite's
/// `gamescope-session-plus` or SteamOS's `gamescope-session`? The sub-mode ladder
/// ([`crate::vdisplay::apply_input_env`]) only defaults to managed when this is true; a plain
/// distro (neither present) falls through to the bare-spawn path instead of the old behaviour of
/// defaulting to managed and then bailing on the missing session script.
pub fn managed_session_available() -> bool {
std::path::Path::new(SESSION_PLUS_BIN).exists()
|| std::path::Path::new(STEAMOS_SESSION_BIN).exists()
}
/// Is a gamescope WE DIDN'T SPAWN running for our uid right now? Used by the sub-mode ladder to
/// pick ATTACH (mirror the foreign session) over a bare spawn on a box without managed-session
/// infra. Our own per-session bare-spawn gamescopes are children of this host process — excluded by
/// walking each candidate's ppid chain — so one client's nested gamescope never makes the next
/// client attach to it.
pub fn foreign_gamescope_running() -> bool {
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
let uid = unsafe { libc::getuid() };
let our_pid = std::process::id();
let Ok(entries) = std::fs::read_dir("/proc") else {
return false;
};
for e in entries.flatten() {
let name = e.file_name();
let Some(pid_str) = name.to_str() else {
continue;
};
let Ok(pid) = pid_str.parse::<u32>() else {
continue;
};
let Ok(md) = std::fs::metadata(e.path()) else {
continue;
};
use std::os::unix::fs::MetadataExt;
if md.uid() != uid {
continue;
}
let Ok(comm) = std::fs::read_to_string(e.path().join("comm")) else {
continue;
};
if !matches!(comm.trim(), "gamescope" | "gamescope-wl") {
continue;
}
if !descends_from(pid, our_pid) {
return true;
}
}
false
}
/// Is `pid` a descendant of (or equal to) `ancestor`? Walks the ppid chain via `/proc/<pid>/stat`
/// with a hop cap so a racing/exiting process can't loop us.
fn descends_from(mut pid: u32, ancestor: u32) -> bool {
for _ in 0..64 {
if pid == ancestor {
return true;
}
if pid <= 1 {
return false;
}
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
return false;
};
// Field 4 (ppid) follows the parenthesized comm — split after the LAST ')' since comm can
// itself contain parentheses.
let Some(rest) = stat.rsplit_once(')').map(|(_, r)| r) else {
return false;
};
let Some(ppid) = rest.split_whitespace().nth(1).and_then(|s| s.parse().ok()) else {
return false;
};
pid = ppid;
}
false
}
/// Launch `cmd` INTO the live gamescope session (the managed / SteamOS / attach modes, where the
/// session already exists and [`spawn`]'s nesting doesn't apply). The child gets the session's own
/// `DISPLAY` (gamescope's Xwayland) and Wayland socket, discovered from a process already inside the
/// session — so X11 and Wayland clients alike land on the streamed gamescope output. Discovery is
/// best-effort: without it we still spawn with the host env and warn (a `steam steam://…` launch
/// still works there — the running Steam instance picks the URI up over its own pipe, no display
/// env needed).
pub fn launch_into_session(cmd: &str) -> Result<std::process::Child> {
let mut c = Command::new("sh");
c.arg("-c").arg(cmd);
match discover_session_display_env() {
Some((x11, wayland)) => {
tracing::info!(
command = %cmd,
x11_display = x11.as_deref().unwrap_or("-"),
wayland = wayland.as_deref().unwrap_or("-"),
"gamescope: launching into the live session"
);
if let Some(d) = x11 {
c.env("DISPLAY", d);
}
if let Some(w) = wayland {
c.env("WAYLAND_DISPLAY", w);
}
}
None => tracing::warn!(
command = %cmd,
"gamescope: could not discover the session's display env — spawning with the host env \
(a `steam steam://…` launch still reaches the running Steam; other apps may not land \
in the session)"
),
}
c.spawn()
.context("spawn launch command into gamescope session")
}
/// Find the live gamescope session's `(DISPLAY, WAYLAND_DISPLAY)` by scanning same-uid processes
/// for one whose environment carries `GAMESCOPE_WAYLAND_DISPLAY` (gamescope sets it for everything
/// it runs — Steam, the game, our own nested `sh`). The Wayland value returned is that gamescope
/// socket; `DISPLAY` is the nested Xwayland. Either can be individually absent.
fn discover_session_display_env() -> Option<(Option<String>, Option<String>)> {
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
let uid = unsafe { libc::getuid() };
for e in std::fs::read_dir("/proc").ok()?.flatten() {
let name = e.file_name();
let Some(pid_str) = name.to_str() else {
continue;
};
if !pid_str.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
let Ok(md) = std::fs::metadata(e.path()) else {
continue;
};
use std::os::unix::fs::MetadataExt;
if md.uid() != uid {
continue;
}
let Ok(raw) = std::fs::read(e.path().join("environ")) else {
continue;
};
let mut display = None;
let mut gs_wayland = None;
for kv in raw.split(|&b| b == 0) {
let kv = String::from_utf8_lossy(kv);
if let Some(v) = kv.strip_prefix("GAMESCOPE_WAYLAND_DISPLAY=") {
if !v.is_empty() {
gs_wayland = Some(v.to_string());
}
} else if let Some(v) = kv.strip_prefix("DISPLAY=") {
if !v.is_empty() {
display = Some(v.to_string());
}
}
}
// Only a process INSIDE a gamescope session (it has the marker var) is a valid source.
if gs_wayland.is_some() {
return Some((display, gs_wayland));
}
}
None
}
/// Run a `systemctl --user` subcommand best-effort — a failure just means the session won't change,
/// which the caller's node-wait surfaces.
fn systemctl_user(args: &[&str]) {