From 0bc60ebc44a713873f8593e786d417b75fb17118 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 14 Jun 2026 19:38:58 +0000 Subject: [PATCH] fix(host/gamescope): free Steam from the autologin TV session while streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Bazzite host that autologins into gaming mode on a physical display (the F44 default: gamescope-session-plus@ogui-steam on the TV), Steam — single-instance — is held by that session, which renders to the TV's native mode. The host-managed session then can't start its own Steam, so it captured the TV's 4K output instead of the client's mode (stretched). On F43 the box wasn't in gaming mode, so the host's Steam was the only one. Fix: on connect, the host-managed gamescope path stops any running autologin `gamescope-session-plus@*` unit (frees Steam) before launching its own session at the client's mode; on client disconnect (`restore_tv_session`, called from serve_session teardown) it stops our session and restarts the autologin one, so the TV returns to gaming mode by default when no one is streaming. Stopping the `--user` unit sticks (Relogin only fires on the full logind session ending — verified live), so no sddm config change is needed. Cost: a Steam cold-start per connect, given single-instance. No-op on non-Bazzite / headless boxes (nothing to stop → nothing to restore). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/m3.rs | 4 ++ crates/punktfunk-host/src/vdisplay.rs | 10 +++ .../punktfunk-host/src/vdisplay/gamescope.rs | 72 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index 766eaf4..d4f9579 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -875,6 +875,10 @@ async fn serve_session( let _ = input_handle.join(); }) .await; + // The capture (and our gamescope session's VirtualOutput) are gone by here. If this was the + // host-managed gamescope path on a box that autologs into gaming mode (Bazzite default), put the + // TV's gaming session back so it's the default when no one is streaming. + crate::vdisplay::restore_managed_session(); result } diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index c584e40..4652369 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -227,6 +227,16 @@ pub fn gamescope_ei_socket_file() -> &'static str { gamescope::EI_SOCKET_FILE } +/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin +/// gaming session (stopped its single-instance Steam to stream at the client's mode), restart that +/// session so the TV returns to gaming mode. No-op on other compositors / when nothing was taken. +#[cfg(target_os = "linux")] +pub fn restore_managed_session() { + gamescope::restore_tv_session(); +} +#[cfg(not(target_os = "linux"))] +pub fn restore_managed_session() {} + #[cfg(target_os = "linux")] mod gamescope; #[cfg(target_os = "linux")] diff --git a/crates/punktfunk-host/src/vdisplay/gamescope.rs b/crates/punktfunk-host/src/vdisplay/gamescope.rs index 0c22039..660ccd1 100644 --- a/crates/punktfunk-host/src/vdisplay/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/gamescope.rs @@ -41,6 +41,10 @@ struct SessionState { /// connections; on host restart the next launch stops the leftover unit by name and starts fresh. static MANAGED_SESSION: std::sync::Mutex> = std::sync::Mutex::new(None); +/// Autologin gaming-mode `gamescope-session-plus@*` units we stopped on connect to free Steam +/// (single-instance), so [`restore_tv_session`] can restart them when the client disconnects. +static STOPPED_AUTOLOGIN: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + /// systemd --user transient unit name for the host-managed gamescope-session-plus session. const SESSION_UNIT: &str = "punktfunk-gamescope"; /// The gamescope-session-plus launcher script (Bazzite / SteamOS-like hosts). @@ -121,6 +125,11 @@ impl VirtualDisplay for GamescopeDisplay { /// otherwise stop the old transient unit and RELAUNCH at the new mode (gamescope can't change output /// mode live). Then discover the node + point the injector, exactly as the attach path does. fn create_managed_session(client: &str, mode: Mode) -> Result { + // Steam is single-instance: if the box autologged into gaming mode on a physical display (the + // Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and + // renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by + // stopping it; [`restore_tv_session`] (called when the client disconnects) brings it back. + stop_autologin_sessions(); let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()); let same_mode = guard.as_ref().is_some_and(|s| { s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz @@ -169,6 +178,69 @@ fn create_managed_session(client: &str, mode: Mode) -> Result { }) } +/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its +/// single-instance Steam is free for our own host-managed session. Records the units so +/// [`restore_tv_session`] can restart them on disconnect. Our own session is the transient +/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing +/// is autologged in (e.g. a box that boots headless). +fn stop_autologin_sessions() { + let Ok(out) = Command::new("systemctl") + .args([ + "--user", + "list-units", + "--type=service", + "--state=running", + "--no-legend", + "--plain", + "gamescope-session-plus@*.service", + ]) + .output() + else { + return; + }; + let mut stopped = Vec::new(); + for line in String::from_utf8_lossy(&out.stdout).lines() { + if let Some(unit) = line.split_whitespace().next() { + if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") { + let _ = Command::new("systemctl") + .args(["--user", "stop", unit]) + .status(); + tracing::info!( + unit, + "freed Steam: stopped the autologin gaming session for this stream" + ); + stopped.push(unit.to_string()); + } + } + } + if !stopped.is_empty() { + *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped; + } +} + +/// Client disconnected: tear down our host-managed session (freeing Steam) and restart the +/// autologin gaming session(s) we stopped on connect — so the TV returns to gaming mode when no one +/// is streaming. Idempotent / safe to call on every session end (no-op when we stopped nothing, +/// e.g. a non-gamescope or headless box). The brief Steam restart is the cost of "TV by default, +/// client mode while streaming" given Steam's single-instance limit. +pub fn restore_tv_session() { + let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner())); + if units.is_empty() { + return; // nothing was stolen → nothing to restore (also the non-Bazzite path) + } + stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin + *MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None; + for unit in units { + let _ = Command::new("systemctl") + .args(["--user", "start", &unit]) + .status(); + tracing::info!( + unit, + "restored the TV's autologin gaming session (client gone)" + ); + } +} + /// Point the libei injector at the running gamescope's EIS socket (it reads the relay file /// [`EI_SOCKET_FILE`]). Best-effort — video still works without it (input just won't reach the /// session). Shared by the attach and host-managed-session paths.