The flatpak's gamescope env stops faking Gaming Mode #464

Merged
enricobuehler merged 1 commits from worktree-gamescope-env-fullscreen into main 2026-08-30 23:38:19 +00:00
8 changed files with 100 additions and 15 deletions
+4 -4
View File
@@ -51,11 +51,11 @@ pub fn deep_link_arg() -> Option<String> {
}
/// 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
+1 -1
View File
@@ -869,7 +869,7 @@ pub fn show_about(parent: &impl IsA<gtk::Widget>) {
/// 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<RefCell<Vec<Box<dyn Fn(u32)>>>>;
+7 -6
View File
@@ -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}"),
+3 -1
View File
@@ -190,7 +190,9 @@ fn declared_kind(setting: GamepadPref, physical: GamepadPref) -> GamepadPref {
pub fn is_steam_deck() -> bool {
static DECK: std::sync::OnceLock<bool> = 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}"));
+79
View File
@@ -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));
}
}
+4
View File
@@ -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")]
+1 -2
View File
@@ -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"))
}
+1 -1
View File
@@ -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!(