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.