From 252960291ec7cedf07fd152eb095adca6e7d6e2a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 15 Jul 2026 20:48:05 +0200 Subject: [PATCH] feat(host/linux): PUNKTFUNK_GAMESCOPE_STEAM opt-in for bare gamescope spawns Adds --steam (before the -- terminator, where PUNKTFUNK_GAMESCOPE_APP cannot reach) to the bare headless gamescope spawn when the env var is truthy, enabling gamescope's Steam integration for steam -gamepadui dedicated sessions. Default off; managed gamescope-session-plus/SteamOS sessions own their own flags and are unaffected. Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/config.rs | 10 +++ .../src/vdisplay/linux/gamescope.rs | 66 +++++++++++-------- 2 files changed, 49 insertions(+), 27 deletions(-) diff --git a/crates/punktfunk-host/src/config.rs b/crates/punktfunk-host/src/config.rs index b6c489a2..06a91116 100644 --- a/crates/punktfunk-host/src/config.rs +++ b/crates/punktfunk-host/src/config.rs @@ -71,6 +71,10 @@ pub struct HostConfig { /// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the /// shipped `host.env` and as a forward seam if a second backend is ever added. pub vdisplay: Option, + /// `PUNKTFUNK_GAMESCOPE_STEAM` — opt the bare headless gamescope spawn into its Steam + /// integration mode (`--steam`). Managed gamescope-session-plus/SteamOS sessions own their + /// own flags and do not consult this. + pub gamescope_steam: bool, /// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO /// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell /// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up @@ -120,6 +124,12 @@ impl HostConfig { compositor: val("PUNKTFUNK_COMPOSITOR"), gamepad: val("PUNKTFUNK_GAMEPAD"), vdisplay: val("PUNKTFUNK_VDISPLAY"), + gamescope_steam: val("PUNKTFUNK_GAMESCOPE_STEAM").is_some_and(|s| { + matches!( + s.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }), recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD") .filter(|s| !s.trim().is_empty()), } diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs index 5ca00f3e..90e7afc0 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs @@ -1359,6 +1359,21 @@ fn shape_dedicated_command(app: &str) -> String { app.to_string() } +/// Add the compositor-side arguments shared by every bare gamescope spawn. `steam_mode` belongs +/// before the `--` terminator; [`PUNKTFUNK_GAMESCOPE_APP`](spawn) configures the nested command +/// after it and therefore cannot enable gamescope's Steam integration itself. +fn add_bare_gamescope_args(command: &mut Command, w: u32, h: u32, hz: u32, steam_mode: bool) { + command + .args(["--backend", "headless"]) + .args(["-W", &w.to_string()]) + .args(["-H", &h.to_string()]) + .args(["-r", &hz.to_string()]); + if steam_mode { + command.arg("--steam"); + } + command.args(["--xwayland-count", "1", "--"]); +} + /// Spawn `gamescope --backend headless -W w -H h -r hz -- `. The app comes from /// `PUNKTFUNK_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real /// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session). @@ -1385,33 +1400,30 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R let app = shape_dedicated_command(&app); let relay = ei_socket_file(); let _ = std::fs::remove_file(&relay); // stale socket path from a previous session + let steam_mode = crate::config::config().gamescope_steam; let mut cmd = Command::new("gamescope"); - cmd.args(["--backend", "headless"]) - .args(["-W", &w.to_string()]) - .args(["-H", &h.to_string()]) - .args(["-r", &hz.to_string()]) - .args(["--xwayland-count", "1", "--"]) - .args([ - "sh", - "-c", - &format!( - "printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"", - relay.display() - ), - "sh", - ]) - .args(app.split_whitespace()) - // Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box). - .env("__GLX_VENDOR_LIBRARY_NAME", "nvidia") - // A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after - // a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale - // WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session) - // makes gamescope 3.16 exit at startup with "Failed to connect to wayland socket" before - // its PipeWire node ever appears (observed 2026-07-14; the boot-started host never saw the - // bug because it predates any login's env import). gamescope exports its own DISPLAY / - // GAMESCOPE_WAYLAND_DISPLAY to the nested app, so the child loses nothing. - .env_remove("DISPLAY") - .env_remove("WAYLAND_DISPLAY"); + add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode); + cmd.args([ + "sh", + "-c", + &format!( + "printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"", + relay.display() + ), + "sh", + ]) + .args(app.split_whitespace()) + // Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box). + .env("__GLX_VENDOR_LIBRARY_NAME", "nvidia") + // A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after + // a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale + // WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session) + // makes gamescope 3.16 exit at startup with "Failed to connect to wayland socket" before + // its PipeWire node ever appears (observed 2026-07-14; the boot-started host never saw the + // bug because it predates any login's env import). gamescope exports its own DISPLAY / + // GAMESCOPE_WAYLAND_DISPLAY to the nested app, so the child loses nothing. + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY"); if let Ok(logf) = std::fs::File::create(log) { if let Ok(log2) = logf.try_clone() { cmd.stdout(Stdio::from(logf)).stderr(Stdio::from(log2)); @@ -1419,7 +1431,7 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R } else { cmd.stdout(Stdio::null()).stderr(Stdio::null()); } - tracing::info!(w, h, hz, %app, log = %log.display(), "spawning gamescope (headless)"); + tracing::info!(w, h, hz, steam_mode, %app, log = %log.display(), "spawning gamescope (headless)"); cmd.spawn() .context("spawn gamescope (is it installed? `apt install gamescope`)") }