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
+124 -23
View File
@@ -369,9 +369,10 @@ fn find_wayland_socket(runtime: &str, uid: u32) -> Option<String> {
/// the default concurrent native sessions each running `resolve_compositor` in its own
/// `spawn_blocking`, the per-session env retargeting would otherwise race and could crash the host
/// (security-review 2026-06-28 #7). Every env write on the setup path takes this lock; steady-state
/// streaming reads cached config, not env. This removes the memory-unsafety; it is NOT a full fix
/// for cross-session env *value* confusion (that needs per-session `SessionContext` threading, as the
/// GameStream/Windows path already does via `set_launch_command`).
/// streaming reads cached config, not env. This removes the memory-unsafety; the launch command is
/// additionally threaded per-session (`SessionContext.launch` → `set_launch_command`) so it never
/// rides the process env at all — the remaining knobs here (session retarget, gamescope sub-mode)
/// still carry a cross-session *value* confusion window inherent to a process-global env.
pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Run `f` with [`ENV_LOCK`] held. Use around any `set_var`/`remove_var` on the session-setup path.
@@ -384,7 +385,7 @@ pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so
/// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and
/// re-applies. Same `set_var` discipline used for `PUNKTFUNK_GAMESCOPE_APP` on the launch path.
/// re-applies.
#[cfg(target_os = "linux")]
pub fn apply_session_env(active: &ActiveSession) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
@@ -468,14 +469,57 @@ pub fn settle_desktop_portal(chosen: Compositor) {
#[cfg(not(target_os = "linux"))]
pub fn settle_desktop_portal(_chosen: Compositor) {}
/// How a gamescope-backed session is realized. Chosen per connect by [`pick_gamescope_mode`],
/// written into the env knobs `GamescopeDisplay::create` dispatches on.
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GamescopeMode {
/// Host-managed `gamescope-session-plus` / SteamOS session at the client's mode.
Managed,
/// Attach to an already-running gamescope (capture + inject, no lifecycle ownership).
Attach,
/// Bare-spawn a headless gamescope per session, nesting the session's launch command.
Spawn,
}
/// Pure sub-mode ladder for gamescope (unit-testable — the env/probe inputs are parameters):
/// explicit `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed; explicit ATTACH/NODE forces attach; an
/// operator-set `PUNKTFUNK_GAMESCOPE_SESSION` keeps managed; otherwise managed only **when the box
/// actually has the session infrastructure** (gamescope-session-plus / SteamOS — the old code
/// defaulted to managed unconditionally and then bailed on a plain distro, killing the session);
/// a foreign (not host-spawned) gamescope on an infra-less box is attached to; and the final
/// default is a per-session bare spawn — the path that nests the client's launch command.
#[cfg(target_os = "linux")]
fn pick_gamescope_mode(
force_managed: bool,
attach_env: bool,
node_env: bool,
session_env: bool,
managed_infra: bool,
foreign_gamescope: bool,
) -> GamescopeMode {
if force_managed {
GamescopeMode::Managed
} else if attach_env || node_env {
GamescopeMode::Attach
} else if session_env || managed_infra {
GamescopeMode::Managed
} else if foreign_gamescope {
GamescopeMode::Attach
} else {
GamescopeMode::Spawn
}
}
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope, the **default is a managed
/// session at the client's mode** (tears the TV's autologin down on connect; restored on a debounced
/// idle) — so the client gets ITS resolution (capture == encode == client mode), not the TV's, and a
/// quick reconnect reuses the warm session (no churn). Opt out to **attach** (mirror the running TV
/// session at its own mode, gaming stays live on the panel, no Steam restart) with
/// `PUNKTFUNK_GAMESCOPE_ATTACH`; an explicit `PUNKTFUNK_GAMESCOPE_NODE` also implies attach, and
/// `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over either.
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope the sub-mode ladder
/// ([`pick_gamescope_mode`]) selects **managed** (a host-managed session at the client's mode —
/// tears the TV's autologin down on connect, restored on a debounced idle; only where
/// session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at its own mode;
/// explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the fallback for a
/// foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless gamescope
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
/// forces managed over all of it.
#[cfg(target_os = "linux")]
pub fn apply_input_env(chosen: Compositor) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
@@ -490,26 +534,61 @@ pub fn apply_input_env(chosen: Compositor) {
};
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
if chosen == Compositor::Gamescope {
let force_managed = std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some();
let attach = !force_managed
&& (std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some()
|| std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some());
if attach {
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() {
std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto");
let mode = pick_gamescope_mode(
std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(),
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_some(),
gamescope::managed_session_available(),
gamescope::foreign_gamescope_running(),
);
tracing::info!(?mode, "gamescope sub-mode");
match mode {
GamescopeMode::Attach => {
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() {
std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto");
}
}
} else {
if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() {
std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam");
GamescopeMode::Managed => {
if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() {
std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam");
}
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
}
GamescopeMode::Spawn => {
// Bare spawn: `create` must fall through to the spawn path, so neither knob may
// linger from an earlier connect's managed/attach selection.
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
}
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
}
}
}
#[cfg(not(target_os = "linux"))]
pub fn apply_input_env(_chosen: Compositor) {}
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
/// into the session — it would start twice. Read AFTER [`apply_input_env`] resolved the gamescope
/// sub-mode (the env knobs are that resolution's output).
#[cfg(target_os = "linux")]
pub fn launch_is_nested(compositor: Compositor) -> bool {
compositor == Compositor::Gamescope
&& with_env_lock(|| {
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none()
&& std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none()
})
}
/// Launch `cmd` into the live gamescope session (managed/attach — see
/// [`gamescope::launch_into_session`]). Split out so `library.rs` doesn't reach into the private
/// backend module.
#[cfg(target_os = "linux")]
pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
gamescope::launch_into_session(cmd)
}
/// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
@@ -692,6 +771,28 @@ mod tests {
assert_eq!(compositor_for_kind(ActiveKind::None), None);
}
#[cfg(target_os = "linux")]
#[test]
fn gamescope_mode_ladder() {
use GamescopeMode::*;
let pick = pick_gamescope_mode;
// (force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
// Plain distro, nothing running: bare spawn — the path that nests the launch command.
assert_eq!(pick(false, false, false, false, false, false), Spawn);
// Bazzite/SteamOS (session infra present): managed, as validated live.
assert_eq!(pick(false, false, false, false, true, false), Managed);
assert_eq!(pick(false, false, false, false, true, true), Managed);
// Foreign gamescope on an infra-less box: attach and mirror it.
assert_eq!(pick(false, false, false, false, false, true), Attach);
// Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra.
assert_eq!(pick(false, false, false, true, false, false), Managed);
// Explicit attach/node wins over infra…
assert_eq!(pick(false, true, false, false, true, false), Attach);
assert_eq!(pick(false, false, true, true, true, false), Attach);
// …and force-managed wins over everything.
assert_eq!(pick(true, true, true, false, false, false), Managed);
}
#[test]
fn detect_active_session_is_side_effect_free_and_terminates() {
// A pure probe of /proc + the runtime dir: it must not panic and must return promptly on