diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index c1f15a34..654f9c5b 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -51,11 +51,11 @@ pub fn deep_link_arg() -> Option { } /// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the -/// console library exec the session binary, which handles its own fullscreen). +/// console library exec the session binary, which handles its own fullscreen). Gaming Mode +/// means gamescope, never `SteamDeck`: that variable says which MACHINE this is, so it is set +/// in desktop mode too, where a fullscreen shell is just wrong. pub fn fullscreen_mode() -> bool { - arg_flag("--fullscreen") - || std::env::var_os("SteamDeck").is_some() - || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() + arg_flag("--fullscreen") || pf_client_core::gamescope::under_gamescope() } /// Split `host[:port]`: no colon defaults the port to 9777; a colon with an unparsable diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index fd4da372..dd2845aa 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -869,7 +869,7 @@ pub fn show_about(parent: &impl IsA) { /// flashes the row but no list ever appears. Selection UI must stay inside the toplevel. fn gamescope_session() -> bool { std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope")) - || std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok() + || pf_client_core::gamescope::under_gamescope() } type ChangedFn = Rc>>>; diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 12e4b573..1984cf91 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -146,10 +146,10 @@ mod session_main { /// Running under Gaming Mode (a Deck, or any gamescope session): the environment /// where the local Steam UI owns the physical Steam/QAM buttons — the system-button - /// "auto" policy keys off this. + /// "auto" policy keys off this. Gaming Mode means gamescope is really there, which is + /// not what the bare env vars say — see [`pf_client_core::gamescope`]. pub(crate) fn gaming_mode() -> bool { - std::env::var_os("SteamDeck").is_some() - || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() + pf_client_core::gamescope::under_gamescope() } /// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a @@ -1055,9 +1055,10 @@ mod session_main { .clone() .map_or_else(|| host_label.clone(), |id| format!("{host_label} · {id}")); - let fullscreen = arg_flag("--fullscreen") - || std::env::var_os("SteamDeck").is_some() - || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some(); + // `--fullscreen` carries the client's own `fullscreen_on_stream`, so the env may only + // ADD to it under a real gamescope — reading it loosely made every flatpak stream + // fullscreen no matter what the setting said. + let fullscreen = arg_flag("--fullscreen") || gaming_mode(); let opts = pf_presenter::SessionOpts { window_title: format!("Punktfunk · {title}"), diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 6e91bf7a..f7b713fe 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -190,7 +190,9 @@ fn declared_kind(setting: GamepadPref, physical: GamepadPref) -> GamepadPref { pub fn is_steam_deck() -> bool { static DECK: std::sync::OnceLock = std::sync::OnceLock::new(); *DECK.get_or_init(|| { - if std::env::var_os("SteamDeck").is_some() { + // Valve documents the VALUE, not the name: desktop Steam exports `SteamDeck=0` into + // everything it launches, so a presence check calls every PC with Steam a Deck. + if std::env::var("SteamDeck").is_ok_and(|v| v.trim() == "1") { return true; } let dmi = |f: &str| std::fs::read_to_string(format!("/sys/class/dmi/id/{f}")); diff --git a/crates/pf-client-core/src/gamescope.rs b/crates/pf-client-core/src/gamescope.rs new file mode 100644 index 00000000..44c61b8b --- /dev/null +++ b/crates/pf-client-core/src/gamescope.rs @@ -0,0 +1,79 @@ +//! "Are we really running under gamescope?" — one answer, because the obvious test lies. +//! +//! `GAMESCOPE_WAYLAND_DISPLAY` used to be read as proof on its own: gamescope exports it to its +//! children, so its presence meant Gaming Mode. **Our own flatpak breaks that.** The manifest sets +//! it unconditionally (`packaging/flatpak/io.unom.Punktfunk.yml`), because the vendored gamescope +//! WSI layer reads that one variable and nothing else to decide whether to negotiate HDR10 — so +//! inside the sandbox it is set on every launch, on every desktop. Every caller that took it for a +//! Gaming-Mode signal therefore fired on a plain GNOME/KDE login: the GTK shell launched +//! fullscreen, a stream ignored `fullscreen_on_stream = false`, the settings dialog swapped its +//! dropdowns for subpages, and the system-button policy picked Deck rules. Field-reported on +//! 2026-08-30 as "the GTK client just launches in fullscreen"; the manifest line landed in +//! `e1adc5d6` (2026-08-05, v0.25.0), which dates the regression. +//! +//! The variable still has to be exported — the WSI layer needs it — so the fix is here, in what +//! *we* accept as proof. + +use std::ffi::OsStr; +use std::path::Path; + +/// True only when a gamescope compositor is actually on the other end. +pub fn under_gamescope() -> bool { + decide( + std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").as_deref(), + std::env::var_os("WAYLAND_DISPLAY").as_deref(), + |name| { + std::env::var_os("XDG_RUNTIME_DIR") + .is_some_and(|dir| Path::new(&dir).join(name).exists()) + }, + ) +} + +/// The rule, split out so it can be tested without mutating the process environment. +/// +/// `WAYLAND_DISPLAY` decides it whenever we have one: a desktop session inside the flatpak still +/// gets `--socket=wayland`, so it names the DESKTOP compositor, and the mismatch is exactly what +/// the WSI layer itself bails on. Gaming Mode leaves us nothing to compare — it runs apps as X11 +/// clients (`DISPLAY=:1`, no `WAYLAND_DISPLAY`) — so there the socket the variable names is the +/// proof, and the flatpak binds it (`--filesystem=xdg-run/gamescope-0`) for HDR anyway. +fn decide( + gamescope: Option<&OsStr>, + wayland: Option<&OsStr>, + socket: impl Fn(&OsStr) -> bool, +) -> bool { + let Some(gamescope) = gamescope else { + return false; + }; + match wayland { + Some(wayland) => wayland == gamescope, + None => socket(gamescope), + } +} + +#[cfg(test)] +mod tests { + use super::decide; + use std::ffi::OsStr; + + /// The flatpak shape that caused the field report: the manifest's variable is set, but we are + /// on an ordinary desktop compositor. Everything else here is a shape that must still say yes. + #[test] + fn only_a_real_gamescope_counts() { + let gs = Some(OsStr::new("gamescope-0")); + let present = |_: &OsStr| true; + let absent = |_: &OsStr| false; + + // Flatpak on a GNOME/KDE desktop: set by the manifest, desktop compositor on the socket. + assert!(!decide(gs, Some(OsStr::new("wayland-0")), absent)); + // ...and it stays no even if some OTHER gamescope (a game) is running on the box. + assert!(!decide(gs, Some(OsStr::new("wayland-0")), present)); + // Nested under a real gamescope: the display we are on IS the one named. + assert!(decide(gs, gs, absent)); + // Gaming Mode as an X11 client: no WAYLAND_DISPLAY, so the live socket is the proof. + assert!(decide(gs, None, present)); + // Same shape with no socket — nothing is listening, so nothing is there. + assert!(!decide(gs, None, absent)); + // Never set at all: not a gamescope session by any reading. + assert!(!decide(None, None, present)); + } +} diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index e2d2a827..daca9a5a 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -93,6 +93,10 @@ pub mod access; // The host's OS-identity chain (mDNS `os=` TXT): sanitize + icon-walk order. Pure string // logic, built everywhere (the Apple/Android ports mirror it rather than link it). pub mod os; +// "Are we really under gamescope?" — our flatpak exports the env var the naive test reads, so +// every Gaming-Mode decision goes through here. Built everywhere: the answer is just "no" off +// Linux, which keeps the callers free of cfgs. +pub mod gamescope; // "A system overlay owns the controller" for gamescope Gaming Mode — the signal behind the // gamepad input mask, which SDL's own focus gate structurally cannot provide there. #[cfg(target_os = "linux")] diff --git a/crates/pf-client-core/src/overlay_focus.rs b/crates/pf-client-core/src/overlay_focus.rs index 2a37b6b8..2c44de9a 100644 --- a/crates/pf-client-core/src/overlay_focus.rs +++ b/crates/pf-client-core/src/overlay_focus.rs @@ -103,8 +103,7 @@ impl OverlayFocus { /// Gaming Mode / any gamescope session — the only place this signal exists. Mirrors the same /// env checks the shells already use to detect Gaming Mode. pub fn gamescope_session() -> bool { - std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() - || std::env::var_os("SteamDeck").is_some() + crate::gamescope::under_gamescope() || std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope")) } diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index a5f7aac3..da5c48b4 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -2928,7 +2928,7 @@ fn apply_capture( static SAID: AtomicBool = AtomicBool::new(false); if !SAID.swap(true, Ordering::Relaxed) { let err = sdl3::get_error(); - if std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() { + if pf_client_core::gamescope::under_gamescope() { tracing::debug!(error = %err, "no keyboard grab under gamescope — chords already ours"); } else { tracing::warn!(