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
+29 -13
View File
@@ -132,19 +132,15 @@ fn run(
"video source: virtual display (native client resolution)" "video source: virtual display (native client resolution)"
); );
// Launch the app's command now that capture is live, for the backends that DON'T nest it via // Launch the app's command now that capture is live, for the backends that DON'T nest it via
// set_launch_command above: Windows (no gamescope) and Linux kwin/mutter/wlroots (which stream // set_launch_command above: Windows (no gamescope) and, on Linux, everything but gamescope's
// the existing desktop, so the app must be spawned into the session to land on the streamed // bare-spawn sub-mode (kwin/mutter/wlroots stream the existing desktop; a managed/attached
// output). Linux gamescope already nested it via set_launch_command, so skip it there. // gamescope is a running session to launch INTO — `launch_session_command` routes both).
#[cfg(windows)]
let launch_here = true;
#[cfg(target_os = "linux")]
let launch_here = compositor != crate::vdisplay::Compositor::Gamescope;
#[cfg(any(windows, target_os = "linux"))]
if launch_here {
// A library title (Steam/Epic/GOG/Xbox/custom, surfaced in /applist) carries its // A library title (Steam/Epic/GOG/Xbox/custom, surfaced in /applist) carries its
// store-qualified id — resolve + launch it against the host's OWN library (the client can // store-qualified id — resolved against the host's OWN library (the client can only pick an
// only pick an existing title, never inject a command). An apps.json entry instead carries // existing title, never inject a command). An apps.json entry instead carries an
// an operator-typed `cmd`. Library id wins when both are set. // operator-typed `cmd`. Library id wins when both are set.
#[cfg(windows)]
{
if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) { if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) {
if let Err(e) = crate::library::launch_gamestream_library(lib_id) { if let Err(e) = crate::library::launch_gamestream_library(lib_id) {
tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title"); tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title");
@@ -158,6 +154,17 @@ fn run(
} }
} }
} }
#[cfg(target_os = "linux")]
if !crate::vdisplay::launch_is_nested(compositor) {
if let Some(cmd) = crate::library::resolve_session_launch(
app.and_then(|a| a.library_id.as_deref()),
app.and_then(|a| a.cmd.as_deref()),
) {
if let Err(e) = crate::library::launch_session_command(compositor, &cmd) {
tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app");
}
}
}
// Rebuild closure: re-open the source on a mid-stream capture loss, RE-DETECTING the live // Rebuild closure: re-open the source on a mid-stream capture loss, RE-DETECTING the live
// compositor — so a Desktop<->Game switch (at the client's fixed mode) is FOLLOWED in place // compositor — so a Desktop<->Game switch (at the client's fixed mode) is FOLLOWED in place
// without a Moonlight reconnect. (A resolution change can't be followed mid-stream on // without a Moonlight reconnect. (A resolution change can't be followed mid-stream on
@@ -248,7 +255,16 @@ fn open_gs_virtual_source(
}; };
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?; let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
// Carry the resolved launch command on the backend instance (per-session) rather than a // Carry the resolved launch command on the backend instance (per-session) rather than a
// process-global env var, so concurrent sessions can't stomp each other's launch target. // process-global env var, so concurrent sessions can't stomp each other's launch target. On
// Linux resolve a library-id selection to its command too, so gamescope's bare spawn nests a
// library title exactly like an apps.json command (it previously nested only `cmd`, silently
// dropping library picks).
#[cfg(target_os = "linux")]
vd.set_launch_command(crate::library::resolve_session_launch(
app.and_then(|a| a.library_id.as_deref()),
app.and_then(|a| a.cmd.as_deref()),
));
#[cfg(not(target_os = "linux"))]
vd.set_launch_command(app.and_then(|a| a.cmd.clone())); vd.set_launch_command(app.and_then(|a| a.cmd.clone()));
let vout = vd let vout = vd
.create(punktfunk_core::Mode { .create(punktfunk_core::Mode {
+64 -30
View File
@@ -1639,19 +1639,13 @@ fn steam_exe() -> Option<std::path::PathBuf> {
None None
} }
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the live /// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
/// session, AFTER capture is up. Used by the GameStream path for the backends that DON'T nest the /// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
/// command via [`VirtualDisplay::set_launch_command`]: Windows (no gamescope) and Linux /// through the compositor-aware [`launch_session_command`] instead.
/// kwin/mutter/wlroots (which stream the existing desktop). The caller skips this for Linux gamescope, #[cfg(windows)]
/// which already nested it. On Windows it runs in the interactive USER session (the host is SYSTEM);
/// on Linux the host is already inside the user's graphical session, so a plain spawn lands the app on
/// the streamed (primary) output.
#[cfg(any(windows, target_os = "linux"))]
pub fn launch_gamestream_command(cmd: &str) -> Result<()> { pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
let cmd = cmd.trim(); let cmd = cmd.trim();
anyhow::ensure!(!cmd.is_empty(), "empty command"); anyhow::ensure!(!cmd.is_empty(), "empty command");
#[cfg(windows)]
{
// cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a // cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a
// client-influenced string (same trust as the custom-store `command` kind). // client-influenced string (same trust as the custom-store `command` kind).
let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None) let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None)
@@ -1659,36 +1653,76 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session"); tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session");
Ok(()) Ok(())
} }
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
/// on the `AppEntry`, resolved from the numeric Moonlight appid) into the interactive Windows user
/// session ([`launch_title`]). The id is resolved against the host's OWN library, so a client can
/// only ever pick an existing title — never inject a command. Linux resolves the id via
/// [`launch_command`] and goes through [`launch_session_command`] instead.
#[cfg(windows)]
pub fn launch_gamestream_library(id: &str) -> Result<()> {
launch_title(id)
}
/// Launch a resolved shell command into the **live Linux session** for the session's compositor —
/// the one launch entry point shared by the native (punktfunk/1) and GameStream planes, called
/// AFTER capture is up so the app renders onto the streamed output. The command is host-resolved
/// (a library id via [`launch_command`], or an operator-typed apps.json/custom command) — never a
/// client-sent string. Best-effort by contract: a failure leaves the user on the (streamed)
/// desktop/session rather than tearing the stream down.
///
/// * **KWin / Mutter / wlroots** — the host runs inside the user's graphical session (the process
/// env was retargeted at it by `apply_session_env`, and the per-session virtual output is
/// promoted primary), so a plain spawn lands the app on the streamed output.
/// * **gamescope (managed / SteamOS / attach)** — the app must open *inside* the running gamescope
/// session: spawned with the session's own `DISPLAY`/Wayland env
/// ([`crate::vdisplay::launch_into_gamescope_session`]). A `steam steam://…` command additionally
/// forwards over the running Steam instance's own pipe, so the dominant Steam case is
/// env-independent.
/// * **gamescope (bare spawn)** — not routed here: the command was nested into the fresh gamescope
/// via `set_launch_command` (the caller gates on `vdisplay::launch_is_nested`).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ pub fn launch_session_command(compositor: crate::vdisplay::Compositor, cmd: &str) -> Result<()> {
let child = std::process::Command::new("sh") let cmd = cmd.trim();
anyhow::ensure!(!cmd.is_empty(), "empty command");
let child = match compositor {
crate::vdisplay::Compositor::Gamescope => {
crate::vdisplay::launch_into_gamescope_session(cmd)?
}
_ => std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(cmd) .arg(cmd)
.spawn() .spawn()
.context("spawn gamestream command")?; .context("spawn launch command")?,
tracing::info!(command = %cmd, pid = child.id(), "gamestream: launched app into the session"); };
tracing::info!(
command = %cmd,
pid = child.id(),
compositor = compositor.id(),
"launched app into the live session"
);
Ok(()) Ok(())
} }
}
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried /// Resolve the launch command for a session app selection on Linux: a store-qualified library id
/// on the `AppEntry`, resolved from the numeric Moonlight appid). Windows spawns it into the interactive /// (from either plane) wins, else the operator-typed command. `None` = nothing to launch (or an
/// user session ([`launch_title`]); Linux resolves its shell command ([`launch_command`]) and runs it /// unknown/recipe-less id — warned, so a client picking a stale title sees why nothing started).
/// into the live session ([`launch_gamestream_command`]). The id is resolved against the host's OWN
/// library, so a client can only ever pick an existing title — never inject a command.
#[cfg(any(windows, target_os = "linux"))]
pub fn launch_gamestream_library(id: &str) -> Result<()> {
#[cfg(windows)]
{
launch_title(id)
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ pub fn resolve_session_launch(library_id: Option<&str>, command: Option<&str>) -> Option<String> {
let cmd = launch_command(id) if let Some(id) = library_id {
.ok_or_else(|| anyhow::anyhow!("library id '{id}' has no launch recipe"))?; match launch_command(id) {
launch_gamestream_command(&cmd) Some(cmd) => return Some(cmd),
None => tracing::warn!(
launch_id = id,
"requested launch id not in this host's library (or no launch recipe) — ignoring"
),
} }
} }
command
.map(str::trim)
.filter(|c| !c.is_empty())
.map(str::to_string)
}
/// The full library: every store's titles merged + the custom entries, sorted by title. /// The full library: every store's titles merged + the custom entries, sorted by title.
pub fn all_games() -> Vec<GameEntry> { pub fn all_games() -> Vec<GameEntry> {
+56 -43
View File
@@ -680,36 +680,12 @@ async fn serve_session(
Punktfunk1Source::Synthetic => None, Punktfunk1Source::Synthetic => None,
}; };
// Resolve a requested library launch (the client sends only the store-qualified id; // A requested library launch (the client sends only the store-qualified id; we look it up
// we look it up in OUR library so a client can't inject a command). The bare-spawn gamescope // in OUR library so a client can't inject a command) is resolved below — after the Welcome,
// backend picks this up via the `PUNKTFUNK_GAMESCOPE_APP` env fallback in `spawn` (on a shared // where it's threaded per-session into the data plane as `SessionContext.launch` (no
// desktop / attach-to-existing session it's a harmless no-op). This is the process-global env // process-global env: the old `PUNKTFUNK_GAMESCOPE_APP` write leaked across sessions, and
// path; the write is serialized via `vdisplay::with_env_lock` so concurrent native-session // only gamescope's bare-spawn path ever read it, so launches on every other backend were
// handshakes can't race the `set_var` (security-review 2026-06-28 #7). The remaining // silently dropped).
// cross-session *value* confusion (B's launch id stomping A's pending gamescope spawn) wants
// the command resolved into the per-session VirtualDisplay via `set_launch_command` (as the
// GameStream path does) — a follow-up; the data-race UB is closed here.
if let Some(id) = hello.launch.as_deref() {
// Linux: resolve the id to a gamescope-nested command and stash it in the env the
// gamescope backend reads. Windows has no gamescope to nest into — the data plane launches
// the title into the interactive user session via `library::launch_title` once capture is
// live (threaded as `SessionContext.launch` below), so there is nothing to do here.
#[cfg(not(windows))]
match crate::library::launch_command(id) {
Some(cmd) => {
tracing::info!(launch_id = id, command = %cmd, "launching library title");
crate::vdisplay::with_env_lock(|| {
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd)
});
}
None => tracing::warn!(
launch_id = id,
"client requested a launch id not in this host's library — ignoring"
),
}
#[cfg(windows)]
let _ = id;
}
// Resolve the client's gamepad-backend preference (pure env/cfg check — no probing // Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
// needed; the actual pads are created lazily by the input thread). // needed; the actual pads are created lazily by the input thread).
@@ -1101,10 +1077,29 @@ async fn serve_session(
let source = opts.source; let source = opts.source;
let (seconds, frames) = (opts.seconds, opts.frames); let (seconds, frames) = (opts.seconds, opts.frames);
let mode = hello.mode; let mode = hello.mode;
// Windows: the store-qualified launch id, threaded into the data plane so the title can be // The session's launch, threaded into the data plane. Windows carries the store-qualified id
// launched into the interactive session once capture is live (no gamescope nesting on Windows). // (spawned into the interactive user session once capture is live); other hosts resolve the id
// to its shell command HERE against the host's own library — a client can only ever pick an
// existing title, never send a command — and the data plane runs it per-backend (nested into a
// bare-spawn gamescope, or spawned into the live session once capture is up).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let launch_for_dp = hello.launch.clone(); let launch_for_dp = hello.launch.clone();
#[cfg(not(target_os = "windows"))]
let launch_for_dp = hello.launch.as_deref().and_then(|id| {
match crate::library::launch_command(id) {
Some(cmd) => {
tracing::info!(launch_id = id, command = %cmd, "resolved library launch for this session");
Some(cmd)
}
None => {
tracing::warn!(
launch_id = id,
"client requested a launch id not in this host's library — ignoring"
);
None
}
}
});
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default) let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated) let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
// Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the // Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the
@@ -1180,7 +1175,6 @@ async fn serve_session(
conn: conn_stream, conn: conn_stream,
stats: stats_dp, stats: stats_dp,
client_label, client_label,
#[cfg(target_os = "windows")]
launch: launch_for_dp, launch: launch_for_dp,
}) })
} }
@@ -2108,7 +2102,8 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)") anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)")
})?; })?;
if !overridden { if !overridden {
// Point input at the same backend and select gamescope ATTACH (no churny managed restart). // Point input at the same backend and resolve the gamescope sub-mode (managed where the
// session infra exists, attach to a foreign gamescope, else per-session bare spawn).
crate::vdisplay::apply_input_env(chosen); crate::vdisplay::apply_input_env(chosen);
} }
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect(); let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
@@ -2747,10 +2742,10 @@ struct SessionContext {
/// Short client label (cert-fingerprint prefix, else peer IP) seeded into the capture meta on /// Short client label (cert-fingerprint prefix, else peer IP) seeded into the capture meta on
/// the first armed stats registration. /// the first armed stats registration.
client_label: String, client_label: String,
/// Windows: the store-qualified library id to launch into the interactive user session once /// The session's requested launch, `None` = none. On Windows the store-qualified library id
/// capture is live (no gamescope nesting on Windows). `None` = no launch requested. Linux uses the /// (spawned into the interactive user session once capture is live); on other hosts the shell
/// gamescope `PUNKTFUNK_GAMESCOPE_APP` path resolved at handshake, so this field is Windows-only. /// command already resolved against the host's own library — nested into gamescope's bare spawn
#[cfg(target_os = "windows")] /// via `set_launch_command`, or spawned into the live session once capture is up.
launch: Option<String>, launch: Option<String>,
} }
@@ -2785,7 +2780,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
conn, conn,
stats, stats,
client_label, client_label,
#[cfg(target_os = "windows")]
launch, launch,
} = ctx; } = ctx;
tracing::info!( tracing::info!(
@@ -2804,6 +2798,12 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends // reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends
// and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates). // and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates).
vd.set_client_identity(endpoint::peer_fingerprint(&conn)); vd.set_client_identity(endpoint::peer_fingerprint(&conn));
// Per-session launch (non-Windows): hand the resolved command to the backend instance so
// gamescope's bare spawn nests it — per-instance, no process-global env, so concurrent sessions
// can't stomp each other's launch target. The other backends' default `set_launch_command` is a
// no-op; they get the command spawned into the live session after capture is up (below).
#[cfg(not(target_os = "windows"))]
vd.set_launch_command(launch.clone());
// IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a // IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a
// reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it // reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it
// to release its monitor (instead of tearing a monitor out from under a still-live session), and // to release its monitor (instead of tearing a monitor out from under a still-live session), and
@@ -2819,16 +2819,29 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
drop(_idd_setup_guard); drop(_idd_setup_guard);
// Windows: capture is live — launch the requested library title into the // Capture is live — launch the requested title so it renders onto the streamed output and
// interactive user session so it renders onto the captured desktop and grabs foreground. Linux // grabs focus. Windows spawns the library id into the interactive user session; Linux spawns
// nests its launch in gamescope instead (the handshake `PUNKTFUNK_GAMESCOPE_APP` path). Best-effort: // the resolved command into the live session for every backend that didn't already nest it
// a launch failure (no recipe for the kind, no interactive user) leaves the user on the desktop. // (gamescope's bare spawn ran it inside the fresh gamescope — launching again would start it
// twice). Best-effort: a launch failure (no recipe, launcher missing, no interactive user)
// leaves the user on the streamed desktop/session, never tears the stream down. Launched ONCE
// here — the mid-stream rebuild paths below must not re-spawn it.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
if let Some(id) = launch.as_deref() { if let Some(id) = launch.as_deref() {
if let Err(e) = crate::library::launch_title(id) { if let Err(e) = crate::library::launch_title(id) {
tracing::warn!(launch_id = id, error = %e, "could not launch requested library title"); tracing::warn!(launch_id = id, error = %e, "could not launch requested library title");
} }
} }
#[cfg(target_os = "linux")]
if let Some(cmd) = launch.as_deref() {
if crate::vdisplay::launch_is_nested(compositor) {
tracing::info!(command = %cmd, "launch nested into the per-session gamescope");
} else if let Err(e) = crate::library::launch_session_command(compositor, cmd) {
tracing::warn!(command = %cmd, error = %e, "could not launch requested title into the session");
}
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
let _ = &launch;
let perf = crate::config::config().perf; let perf = crate::config::config().perf;
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ this bursts out immediately; // Microburst cap (applied in send_loop/paced_submit): a frame ≤ this bursts out immediately;
+118 -17
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 /// 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 /// `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 /// (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 /// streaming reads cached config, not env. This removes the memory-unsafety; the launch command is
/// for cross-session env *value* confusion (that needs per-session `SessionContext` threading, as the /// additionally threaded per-session (`SessionContext.launch` → `set_launch_command`) so it never
/// GameStream/Windows path already does via `set_launch_command`). /// 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(()); 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. /// 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` / /// 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 /// `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 /// 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")] #[cfg(target_os = "linux")]
pub fn apply_session_env(active: &ActiveSession) { pub fn apply_session_env(active: &ActiveSession) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); 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"))] #[cfg(not(target_os = "linux"))]
pub fn settle_desktop_portal(_chosen: Compositor) {} 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 /// 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 /// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope the sub-mode ladder
/// session at the client's mode** (tears the TV's autologin down on connect; restored on a debounced /// ([`pick_gamescope_mode`]) selects **managed** (a host-managed session at the client's mode —
/// idle) — so the client gets ITS resolution (capture == encode == client mode), not the TV's, and a /// tears the TV's autologin down on connect, restored on a debounced idle; only where
/// quick reconnect reuses the warm session (no churn). Opt out to **attach** (mirror the running TV /// session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at its own mode;
/// session at its own mode, gaming stays live on the panel, no Steam restart) with /// explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the fallback for a
/// `PUNKTFUNK_GAMESCOPE_ATTACH`; an explicit `PUNKTFUNK_GAMESCOPE_NODE` also implies attach, and /// foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless gamescope
/// `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over either. /// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
/// forces managed over all of it.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn apply_input_env(chosen: Compositor) { pub fn apply_input_env(chosen: Compositor) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); 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); std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
if chosen == Compositor::Gamescope { if chosen == Compositor::Gamescope {
let force_managed = std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(); let mode = pick_gamescope_mode(
let attach = !force_managed std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
&& (std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").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_NODE").is_some(),
if attach { 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"); std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() { if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() {
std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto"); std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto");
} }
} else { }
GamescopeMode::Managed => {
if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() { if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() {
std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam"); std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam");
} }
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE"); 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");
}
}
} }
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub fn apply_input_env(_chosen: Compositor) {} 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 /// 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 /// 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. /// 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); 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] #[test]
fn detect_active_session_is_side_effect_free_and_terminates() { 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 // A pure probe of /proc + the runtime dir: it must not panic and must return promptly on
@@ -233,6 +233,165 @@ fn steamos_session_present() -> bool {
&& !std::path::Path::new(SESSION_PLUS_BIN).exists() && !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, /// Run a `systemctl --user` subcommand best-effort — a failure just means the session won't change,
/// which the caller's node-wait surfaces. /// which the caller's node-wait surfaces.
fn systemctl_user(args: &[&str]) { fn systemctl_user(args: &[&str]) {