From 7580bd70fdedeb1a5437f78b223c292c36cff2ea Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 31 Aug 2026 00:43:59 +0200 Subject: [PATCH] fix(clients): the flatpak's gamescope env stops faking Gaming Mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field report on 2026-08-30: "the GTK client is just launching in fullscreen". Reproduced on a plain GNOME desktop (.21), launched from the .desktop entry, with no Deck and no gamescope anywhere on the box. The shell fullscreens itself when `cli::fullscreen_mode()` is true, and that read `GAMESCOPE_WAYLAND_DISPLAY` as proof of Gaming Mode. Our own flatpak sets it unconditionally: `packaging/flatpak/io.unom.Punktfunk.yml` exports `GAMESCOPE_WAYLAND_DISPLAY=gamescope-0` because the vendored gamescope WSI layer reads that variable and nothing else to decide whether to negotiate HDR10 (e1adc5d6, 2026-08-05, shipped in v0.25.0). So inside the sandbox it is set on every launch, on every desktop — verified in the sandbox on .21, where the host has neither a gamescope process nor a socket. The flatpak is the main Linux channel, so this hit every flatpak user on every desktop since v0.25.0. Fullscreen was only the visible half. The same reading also meant: - a stream ignored `fullscreen_on_stream = false` (the env ORed straight past the flag the client resolves the setting into); - the settings dialog swapped every dropdown for a subpage, the workaround meant for gamescope's unmapped popovers; - the system-button "auto" policy picked Deck rules, handing Steam/QAM to a local Steam UI that is not there; - the overlay-focus watcher took the gamescope path. The variable has to keep being exported, so the fix is in what we accept as proof. `pf_client_core::gamescope::under_gamescope()` is now the one answer, and all six readers route through it: `WAYLAND_DISPLAY` settles it whenever we have one (a desktop session in the sandbox still gets `--socket=wayland`, so it names the desktop compositor — the same mismatch the WSI layer bails on), and Gaming Mode, which runs apps as X11 clients with no `WAYLAND_DISPLAY`, falls back to the named socket actually existing. Dropped the `SteamDeck` leg from the Gaming-Mode tests while here: it says which MACHINE this is, so a Deck in DESKTOP mode was fullscreening too. `is_steam_deck()` still wants machine identity and keeps it, but now compares the value — Valve documents `SteamDeck=1`, and desktop Steam exports `SteamDeck=0` into everything it launches, so the presence check called every PC with Steam a Deck. Verified on .21 (CachyOS, GNOME): `cargo fmt --all --check` clean; the new `only_a_real_gamescope_counts` test passes; `cargo clippy -D warnings --all-targets` green over pf-client-core, pf-presenter, punktfunk-client-linux and punktfunk-client-session, with all four confirmed really built in the log rather than skipped as fresh. --- clients/linux/src/cli.rs | 8 +-- clients/linux/src/ui_settings.rs | 2 +- clients/session/src/main.rs | 13 ++-- crates/pf-client-core/src/gamepad.rs | 4 +- crates/pf-client-core/src/gamescope.rs | 79 ++++++++++++++++++++++ crates/pf-client-core/src/lib.rs | 4 ++ crates/pf-client-core/src/overlay_focus.rs | 3 +- crates/pf-presenter/src/run.rs | 2 +- 8 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 crates/pf-client-core/src/gamescope.rs 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!( -- 2.54.0