diff --git a/Cargo.lock b/Cargo.lock index b77afa8d..b0f8e71b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3047,6 +3047,7 @@ dependencies = [ "wasapi", "windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)", "winreg", + "x11rb", ] [[package]] diff --git a/crates/pf-client-core/Cargo.toml b/crates/pf-client-core/Cargo.toml index ba46cdf4..c09165ca 100644 --- a/crates/pf-client-core/Cargo.toml +++ b/crates/pf-client-core/Cargo.toml @@ -134,6 +134,12 @@ pf-vaadec = { path = "../pf-vaadec" } # container can then compile and clippy the whole rung without `libva-dev`, and a machine # without a VAAPI runtime gets a clean refusal instead of a packaging dependency. libloading = "0.8" +# The gamescope overlay watcher (`overlay_focus`): read two CARDINAL properties off a +# gamescope root window and block on PropertyNotify. `default-features = false` keeps the +# pure-Rust `RustConnection` — no libxcb link, so no new C dependency on any client package +# — the same stance pf-capture and pf-vdisplay already take on this crate. No extension +# features: root-window properties and an event mask are core X11. +x11rb = { version = "0.13", default-features = false } [target.'cfg(windows)'.dependencies] wasapi = "0.23" diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index f2aeec00..aa6b4ad7 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -381,6 +381,7 @@ enum Ctl { PadAudioPrefs(u8), MenuMode(bool), MenuRumble(MenuPulse), + Mask(bool), } #[derive(Clone)] @@ -548,6 +549,31 @@ impl GamepadService { let _ = self.ctl.send(Ctl::Forwarding(on)); } + /// A system overlay owns the controller right now — hold every forwarded pad NEUTRAL + /// until it closes. This is the Steam Input behaviour a streaming client has to + /// reproduce by hand: while the Deck's Steam menu or QAM is up, the same physical + /// sticks and buttons drive Steam's UI, and anything we keep forwarding lands in the + /// game underneath as a second, invisible player. + /// + /// **Masking is not [`set_forwarding`](Self::set_forwarding).** Forwarding-off closes the + /// slot and sends the host a [`GamepadRemove`](InputKind::GamepadRemove) — the game sees a + /// controller *unplug*, which is a hardware event with real in-game consequences (pause + /// menus, "reconnect your controller", player-slot churn). Opening the QAM must not look + /// like that. Masking keeps every slot open and merely stops the transitions, after + /// flushing what the host believes is held so a stick held at overlay-open stops steering + /// instead of freezing at its last value. + /// + /// SDL has this gate of its own — it drops presses while the process has windows but no + /// keyboard focus — and on a desktop it fires. It CANNOT fire on a Deck in Gaming Mode: + /// gamescope resolves focus per Xwayland ctx, and the client sits alone in its own ctx, so + /// its X input focus never moves when the overlay takes over (measured). That is why this + /// exists as an explicit lever rather than something inherited for free. + /// + /// Held state is adopted, not replayed, on the way back — see [`Ctl::Mask`]'s handling. + pub fn set_masked(&self, on: bool) { + let _ = self.ctl.send(Ctl::Mask(on)); + } + /// The session's system-button policy, resolved from /// [`Settings::system_buttons_forward`] × [`Settings::guide_gesture_enabled`]: /// `forward_raw` gates the physical guide/QAM presses onto the wire (off = they stay @@ -1069,6 +1095,9 @@ struct Worker { menu_mode: bool, menu_nav: MenuNav, menu_tx: async_channel::Sender, + /// A system overlay owns input ([`GamepadService::set_masked`]): forwarded pads are held + /// neutral and menu translation is paused, with every slot still OPEN. + masked: bool, } impl Worker { @@ -1519,6 +1548,87 @@ impl Worker { } } + /// Re-adopt what the pads are physically holding when an overlay mask lifts. + /// + /// Buttons are taken back into `held_buttons` **without** a wire press: a button pressed + /// inside the overlay (the A that picked a QAM row) must not fire in the game the instant it + /// closes — releasing it and pressing again is what arms it. Same rule menu mode already + /// applies across a screen handoff ([`MenuNav::reset`]), for the same reason. + /// + /// Axes ARE re-sent, because a stick has no press semantics to ghost — it is deflected or it + /// is not. The mask flushed them to zero, and SDL only speaks on *change*, so a stick still + /// held when the overlay closes would stay dead host-side until the user happened to move it. + /// + /// Neither half can run against a pad that is gone: this only walks open slots, and every SDL + /// read here is a state query on a handle the slot owns. + fn readopt_held(&mut self) { + use sdl3::gamepad::{Axis, Button}; + // Every button `button_bit` maps — the same surface the press path forwards. + const BUTTONS: [Button; 21] = [ + Button::South, + Button::East, + Button::West, + Button::North, + Button::Back, + Button::Start, + Button::Guide, + Button::LeftStick, + Button::RightStick, + Button::LeftShoulder, + Button::RightShoulder, + Button::DPadUp, + Button::DPadDown, + Button::DPadLeft, + Button::DPadRight, + Button::Touchpad, + Button::RightPaddle1, + Button::LeftPaddle1, + Button::RightPaddle2, + Button::LeftPaddle2, + Button::Misc1, + ]; + const AXES: [Axis; 6] = [ + Axis::LeftX, + Axis::LeftY, + Axis::RightX, + Axis::RightY, + Axis::TriggerLeft, + Axis::TriggerRight, + ]; + // Copied out: the slot walk below borrows `self` mutably. + let system_forward = self.system_forward; + let attached = self.attached.clone(); + for slot in &mut self.slots { + slot.held_buttons.clear(); + for b in BUTTONS { + let Some(bit) = button_bit(b) else { + continue; + }; + // The press path returns before `held_buttons` for un-forwarded system + // buttons; tracking them here would invent state it never keeps. + if !system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) { + continue; + } + if slot.pad.button(b) { + slot.held_buttons.push(bit); + } + } + let Some(c) = &attached else { + continue; + }; + for a in AXES { + let (id, v) = axis_value(a, slot.pad.axis(a)); + if slot.last_axis[id as usize] != v { + slot.last_axis[id as usize] = v; + send(c, InputKind::GamepadAxis, id, v, slot.index); + } + } + } + // The chord latch was cleared on the way in; drop it again if what we just adopted + // doesn't actually hold it. + self.rearm_escape(); + } + /// True when any one forwarded pad holds the entire escape chord (any player can leave). fn chord_held(&self) -> bool { self.slots @@ -1785,6 +1895,34 @@ impl Worker { .push((pad, bit, Instant::now() + TAP_PRESS)); } } + Ok(Ctl::Mask(on)) => { + if self.masked == on { + continue; + } + self.masked = on; + if on { + // Neutral NOW, and while the slots stay open: a stick held when the + // overlay opened must stop steering, but the host must not see the pad + // unplug (that is `close_slot_at`'s job, and a game reacts to it). + if let Some(c) = self.attached.clone() { + for slot in &mut self.slots { + Self::flush_slot(&c, slot); + } + } + // Nothing can be mid-chord across the flip: the transitions that would + // complete or break it are about to be dropped. + self.reset_chord(); + } else { + // Coming back. Whatever is still physically held was never delivered — + // adopt it silently rather than replay it as a fresh press, the same + // rule menu mode uses across a screen handoff (`MenuNav::reset`). A + // button you pressed *inside* the overlay must not fire in the game the + // instant it closes; releasing and pressing again is what arms it. + self.readopt_held(); + self.menu_nav.reset(); + } + tracing::info!(masked = on, "overlay input mask"); + } Ok(Ctl::Forwarding(on)) => { if self.forwarding == on { continue; @@ -1846,6 +1984,28 @@ impl Worker { /// "is a session live". fn handle_event(&mut self, event: sdl3::event::Event) { use sdl3::event::Event; + // A system overlay owns the controller ([`GamepadService::set_masked`]): drop every + // input transition. The pads were flushed neutral when the mask went on, so dropping + // the ups as well as the downs is what keeps the two in agreement — `readopt_held` + // rebuilds the held set from the hardware when it lifts. + // + // Device add/remove deliberately still count: a controller genuinely plugged in or + // pulled out behind an overlay is a fact about the world, not an input, and losing it + // would leave the slot table lying about what exists. + if self.masked + && matches!( + event, + Event::ControllerButtonDown { .. } + | Event::ControllerButtonUp { .. } + | Event::ControllerAxisMotion { .. } + | Event::ControllerTouchpadDown { .. } + | Event::ControllerTouchpadMotion { .. } + | Event::ControllerTouchpadUp { .. } + | Event::ControllerSensorUpdated { .. } + ) + { + return; + } match event { Event::ControllerDeviceAdded { which, .. } => { if !self.order.contains(&which) { @@ -2074,7 +2234,9 @@ impl Worker { /// on and no session is attached (attach supersedes; SDL events merely wake the loop, /// so a press is translated the iteration it arrives). fn menu_poll(&mut self) { - if !self.menu_mode || self.attached.is_some() { + // Masked covers the launcher too: with the Deck's Steam menu up over our console, the + // same stick that scrolls Steam's UI would otherwise also be scrolling ours behind it. + if !self.menu_mode || self.attached.is_some() || self.masked { return; } let Some((_, pad)) = self.menu_open.as_ref() else { @@ -2301,6 +2463,7 @@ impl Worker { menu_mode: false, menu_nav: MenuNav::new(), menu_tx, + masked: false, } } } diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index 3acbb5be..2c985143 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -46,6 +46,10 @@ pub mod orchestrate; // 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; +// "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")] +pub mod overlay_focus; // Client settings profiles: the override catalog + the one connect-time resolver // (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records // the bindings live on. diff --git a/crates/pf-client-core/src/overlay_focus.rs b/crates/pf-client-core/src/overlay_focus.rs new file mode 100644 index 00000000..6ef294fc --- /dev/null +++ b/crates/pf-client-core/src/overlay_focus.rs @@ -0,0 +1,284 @@ +//! "A system overlay owns the controller right now" — the gamescope half of the input mask. +//! +//! On a Steam Deck in Gaming Mode the Steam menu and the QAM are drawn by Steam and driven by +//! the *same physical controller* the client is forwarding. Steam does not mask us the way it +//! masks a normal game: masking happens on Steam Input's virtual pad, and the client +//! deliberately forwards the REAL pad instead (28DE:1205 — the virtual one has no gyro, +//! trackpads or paddles). So while the QAM is up, one thumbstick drives Steam's UI *and* the +//! game on the host. This watcher is what tells [`crate::gamepad::GamepadService::set_masked`] +//! to stop that. +//! +//! **Why the free mechanism can't do it.** SDL already drops gamepad presses while the process +//! has windows but no keyboard focus (`SDL_PrivateJoystickShouldIgnoreEvent`, on by default — +//! we never set `SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS`), and on a desktop that fires. It cannot +//! fire here: gamescope resolves focus **per Xwayland ctx** (`determine_and_apply_focus` scans +//! only that ctx's window list), the Steam overlay lives in the root ctx, and the client sits +//! alone in its own. Measured on a Deck 2026-08-08: with the QAM open, X input focus inside the +//! client's ctx never moved off its window, so no `FocusOut` is ever generated. Hence an +//! explicit signal. +//! +//! **The signal.** gamescope publishes two CARDINALs on the ROOT ctx's root window (Steam mode +//! only, i.e. `gamescope -e` — which is what Gaming Mode runs): +//! +//! * `GAMESCOPE_FOCUSED_APP` — appid of the window holding **input** focus +//! * `GAMESCOPE_FOCUSED_APP_GFX` — appid of the window being **displayed** +//! +//! They are equal in normal play and diverge exactly while something else has taken input over +//! the running app. Measured, both for the Steam menu and for the QAM: +//! +//! ```text +//! app=3856846079 gfx=3856846079 ← streaming, we own input +//! app=769 gfx=3856846079 ← overlay open (769 = Steam) +//! ``` +//! +//! Note `app != gfx` rather than "app is Steam": anything that takes input away from the +//! displayed app is a thing we should stop forwarding through, and comparing to our own appid +//! would need us to know it (a non-Steam shortcut's appid is assigned by Steam at creation). +//! +//! **Which display.** Not necessarily ours. Gaming Mode runs `gamescope --xwayland-count 2`: +//! Steam and the atoms live on the first server, the app is given the second, and the client's +//! own `$DISPLAY` therefore has none of these properties. So discovery walks candidates — our +//! `$DISPLAY` first (correct for a single-server gamescope), then every socket in +//! `/tmp/.X11-unix` — and keeps the first whose root actually carries both atoms. gamescope's +//! Xwayland accepts unauthenticated local connections (verified: `xprop` against it succeeds +//! with no `.Xauthority` at all), so no cookie plumbing is needed. +//! +//! Everything here is best-effort by construction: no gamescope, no X, a sandbox that cannot +//! see the other socket, or a session that restarts underneath us all end in "no signal", which +//! degrades to exactly the behaviour that shipped before this module existed. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{ + Atom, AtomEnum, ChangeWindowAttributesAux, ConnectionExt, EventMask, Window, +}; +use x11rb::protocol::Event; +use x11rb::rust_connection::RustConnection; + +/// How long to wait before rebuilding everything after the X connection drops. Gaming Mode +/// recreates its Xwayland servers across a session restart, so "gone" is not permanent — but it +/// is also not worth a hot retry loop. +const RECONNECT_DELAY: Duration = Duration::from_secs(3); + +/// Live "an overlay owns input" flag, updated by a background thread. +/// +/// Cheap to poll (one relaxed atomic load), which is what the presenter's event loop wants — it +/// checks once per iteration and only talks to the gamepad service on an edge. +pub struct OverlayFocus { + open: Arc, +} + +impl OverlayFocus { + /// Start watching, or return `None` when this isn't a gamescope Steam session (the common + /// case — every desktop client) or the user opted out with `PUNKTFUNK_OVERLAY_MASK=0`. + /// + /// Returning `None` is not a failure: the caller keeps its window-focus path, which is the + /// right signal everywhere the compositor actually moves focus. + pub fn start() -> Option { + if std::env::var("PUNKTFUNK_OVERLAY_MASK").is_ok_and(|v| v == "0" || v == "false") { + tracing::info!("overlay input mask disabled by PUNKTFUNK_OVERLAY_MASK"); + return None; + } + if !gamescope_session() { + return None; + } + let open = Arc::new(AtomicBool::new(false)); + let flag = open.clone(); + std::thread::Builder::new() + .name("punktfunk-overlay-focus".into()) + .spawn(move || watch(&flag)) + .map_err(|e| tracing::warn!(error = %e, "overlay focus watcher failed to start")) + .ok()?; + Some(OverlayFocus { open }) + } + + /// Does something other than the displayed app own input right now? + pub fn is_open(&self) -> bool { + self.open.load(Ordering::Relaxed) + } +} + +/// Gaming Mode / any gamescope session — the only place this signal exists. Mirrors the same +/// env checks the shells already use to detect Gaming Mode. +fn gamescope_session() -> bool { + std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() + || std::env::var_os("SteamDeck").is_some() + || std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope")) +} + +/// Displays worth trying, in order: ours first (a single-server gamescope publishes the atoms on +/// the display the app is already on), then every other socket present. `/tmp/.X11-unix` is +/// listed rather than probing `:0..:N` blindly so we never connect to a display that isn't there. +fn candidate_displays() -> Vec { + let mut out = Vec::new(); + if let Ok(d) = std::env::var("DISPLAY") { + if !d.is_empty() { + out.push(d); + } + } + if let Ok(entries) = std::fs::read_dir("/tmp/.X11-unix") { + let mut found: Vec = entries + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let n = name.strip_prefix('X')?; + n.parse::().ok().map(|n| format!(":{n}")) + }) + .collect(); + found.sort(); + for d in found { + if !out.contains(&d) { + out.push(d); + } + } + } + out +} + +/// The two atoms on a root that carries them, or `None` for a display that isn't gamescope's +/// root ctx. `only_if_exists` keeps this from interning atoms into unrelated X servers. +fn gamescope_atoms(conn: &RustConnection) -> Option<(Atom, Atom)> { + let app = conn + .intern_atom(true, b"GAMESCOPE_FOCUSED_APP") + .ok()? + .reply() + .ok()? + .atom; + let gfx = conn + .intern_atom(true, b"GAMESCOPE_FOCUSED_APP_GFX") + .ok()? + .reply() + .ok()? + .atom; + (app != 0 && gfx != 0).then_some((app, gfx)) +} + +/// Read one CARDINAL appid. gamescope writes these with a length of ZERO when the appid is 0 +/// (`focusedAppId != 0 ? 1 : 0`), so "present but empty" is a real state meaning "no app" — it +/// must read as `None`, not as `Some(0)` that would then compare unequal to everything. +fn read_appid(conn: &RustConnection, root: Window, atom: Atom) -> Option { + let reply = conn + .get_property(false, root, atom, AtomEnum::CARDINAL, 0, 1) + .ok()? + .reply() + .ok()?; + // Bound rather than returned inline: the iterator borrows `reply`, and as a tail + // expression its temporary would outlive it. + let id = reply.value32()?.next(); + id +} + +/// The whole decision, separated from X so it can be tested: an overlay is up exactly when +/// input focus and the displayed app are both known and DIFFER. +/// +/// Absence is never an overlay. A missing value means "no app focused" (gamescope's zero-length +/// write) or "this display stopped answering" — and a mask that latched on when the signal went +/// away would silently kill the controller for the rest of the session, which is a far worse +/// failure than not masking at all. +fn overlay_open_from(app: Option, gfx: Option) -> bool { + matches!((app, gfx), (Some(a), Some(g)) if a != g) +} + +/// True when input focus and the displayed app have diverged — an overlay is up. +fn overlay_open(conn: &RustConnection, root: Window, app: Atom, gfx: Atom) -> bool { + overlay_open_from(read_appid(conn, root, app), read_appid(conn, root, gfx)) +} + +/// Connect, find the root ctx, then block on PropertyNotify for the two atoms. Returns on any X +/// error so the outer loop can rebuild after a session restart. +fn watch(flag: &Arc) { + loop { + if let Some((conn, root, app, gfx)) = connect() { + // Seed before the first event: the overlay may already be up when we start. + flag.store(overlay_open(&conn, root, app, gfx), Ordering::Relaxed); + loop { + match conn.wait_for_event() { + Ok(Event::PropertyNotify(e)) if e.atom == app || e.atom == gfx => { + let open = overlay_open(&conn, root, app, gfx); + if flag.swap(open, Ordering::Relaxed) != open { + tracing::debug!(open, "gamescope overlay focus changed"); + } + } + Ok(_) => {} + Err(e) => { + tracing::info!(error = %e, "gamescope focus watcher disconnected"); + break; + } + } + } + // A dropped connection tells us nothing about the controller — unmask, or a + // gamescope restart mid-overlay would leave the pad dead with nothing to revive it. + flag.store(false, Ordering::Relaxed); + } + std::thread::sleep(RECONNECT_DELAY); + } +} + +/// The first candidate display whose root carries both atoms, with PropertyNotify selected. +fn connect() -> Option<(RustConnection, Window, Atom, Atom)> { + for dpy in candidate_displays() { + // `dpy`, not `display`: `display` is one of tracing's own value helpers, and a field + // named after it resolves to the helper inside the macro rather than to this string. + let Ok((conn, screen_num)) = RustConnection::connect(Some(&dpy)) else { + continue; + }; + let Some((app, gfx)) = gamescope_atoms(&conn) else { + continue; + }; + let root = conn.setup().roots[screen_num].root; + // Both atoms must actually be PRESENT on this root, not merely interned: a second + // gamescope Xwayland knows the atom names (they are per-server strings) but only the + // root ctx publishes the values. + if read_appid(&conn, root, gfx).is_none() { + continue; + } + // Checked rather than fire-and-forget: an event mask that silently failed to apply + // would leave the watcher blocked forever on a display that never speaks to it. + let selected = match conn.change_window_attributes( + root, + &ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE), + ) { + Ok(cookie) => cookie.check().is_ok(), + Err(_) => false, + }; + if !selected { + continue; + } + tracing::info!(dpy, "watching gamescope focus for overlay input masking"); + return Some((conn, root, app, gfx)); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The measured Deck states, both directions (2026-08-08, Steam menu and QAM alike): + /// equal appids while we own input, divergent while the overlay does. + #[test] + fn divergent_appids_are_an_overlay() { + assert!(!overlay_open_from(Some(3856846079), Some(3856846079))); + assert!(overlay_open_from(Some(769), Some(3856846079))); + } + + /// gamescope writes these properties with a length of ZERO when the appid is 0, so "no app" + /// arrives as a missing value rather than `Some(0)`. Reading it as `Some(0)` would make it + /// differ from every real appid and mask the pad on an empty Gaming Mode home screen. + #[test] + fn a_missing_appid_is_never_an_overlay() { + assert!(!overlay_open_from(None, Some(3856846079))); + assert!(!overlay_open_from(Some(769), None)); + assert!(!overlay_open_from(None, None)); + } + + /// The safety property that outranks the feature: if the signal is unreadable we forward as + /// before. A latched mask would leave a streaming session with a dead controller and no way + /// back short of restarting it. + #[test] + fn absence_fails_open_not_closed() { + assert!(!overlay_open_from(None, None)); + } +} diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index c8560207..fb8bbb80 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -646,6 +646,18 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result // translation automatically — the GTK launcher never turned it off either). gamepad.set_menu_mode(true); } + // Gaming Mode's Steam menu / QAM drive the SAME physical pad we forward, and gamescope + // never takes our X focus away (it resolves focus per Xwayland ctx, and we are alone in + // ours), so SDL's own background-input gate cannot fire there. `None` everywhere else, + // where window focus IS the signal — see the FocusLost/FocusGained arms below. + #[cfg(target_os = "linux")] + let overlay_focus = pf_client_core::overlay_focus::OverlayFocus::start(); + // Two independent reasons the pad is not ours — window focus and the gamescope overlay — + // OR'd into ONE value that is pushed to the service on an edge. Kept as separate inputs + // rather than one flag each source writes: either would otherwise clear the other's mask + // (a focus-loss mask undone by the next overlay poll saying "no overlay", and vice versa). + let mut focus_lost = false; + let mut mask_applied = false; // The native display mode — the `0 = native` fallback for the requested stream mode // (the GTK client reads the monitor under its window; same idea). @@ -758,8 +770,17 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result tracing::info!("focus lost — input released"); } } + // Controllers go with the keyboard and mouse. SDL already stops + // delivering their PRESSES here, but nothing zeroed what the host + // still believes is held — so a stick deflected at the moment focus + // went away kept steering. Masking flushes it neutral. + focus_lost = true; } WindowEvent::FocusGained => { + // Unlike capture, the controller mask has no "the user meant it" + // variant to respect — it exists only to mirror who owns the pad — + // so regaining focus always lifts its half. + focus_lost = false; // An auto-release (Alt-Tab) undoes itself; a chord release // stays released until the user opts back in. if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { @@ -1070,6 +1091,18 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result other => pump.handle_event(other), } } + // Who owns the pad right now: window focus, plus Gaming Mode's overlay signal where it + // exists (one relaxed atomic load; `None` off gamescope). Edge-triggered — the service + // hears only about CHANGES, so an open QAM doesn't re-flush the pads every iteration. + #[cfg(target_os = "linux")] + let overlay_now = overlay_focus.as_ref().is_some_and(|of| of.is_open()); + #[cfg(not(target_os = "linux"))] + let overlay_now = false; + let want_mask = focus_lost || overlay_now; + if want_mask != mask_applied { + mask_applied = want_mask; + gamepad.set_masked(want_mask); + } pump.tick(); // One coalesced MouseMove per iteration — pure motion must reach the host // without waiting for a click/key to flush it. diff --git a/packaging/flatpak/io.unom.Punktfunk.yml b/packaging/flatpak/io.unom.Punktfunk.yml index 4e7bfa32..67cfac28 100644 --- a/packaging/flatpak/io.unom.Punktfunk.yml +++ b/packaging/flatpak/io.unom.Punktfunk.yml @@ -65,6 +65,14 @@ finish-args: - --socket=wayland # GTK4 native Wayland window (the client is Wayland-first) - --socket=fallback-x11 # Xwayland fallback when no Wayland socket is exposed - --share=ipc # required alongside X11 for shared-memory surfaces + # Gaming Mode's overlay signal lives on a DIFFERENT X server than ours. gamescope runs + # `--xwayland-count 2`: Steam and the GAMESCOPE_FOCUSED_APP/_GFX atoms are on the first, + # the app is handed the second, so `$DISPLAY` alone can never see them — and --socket=x11 + # would not help, since flatpak binds only the ONE socket named by DISPLAY. Read-only + # access to the socket directory is what lets `overlay_focus` reach the root ctx and stop + # forwarding the pad while the Steam menu / QAM is up. gamescope's Xwayland takes + # unauthenticated local connections, so no cookie has to cross with it. + - --filesystem=/tmp/.X11-unix:ro # --- GPU + all input devices --- # --device=all (not just --device=dri): covers the GPU render node (VAAPI HEVC decode + GL), # evdev joysticks, AND the hidraw CHAR devices SDL3's HIDAPI needs for DualSense touchpad/