feat(client/pads): stop forwarding the pad while the Steam overlay owns it
ci / bun-nix (pull_request) Successful in 32s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m37s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 5m50s
android / android (pull_request) Successful in 7m56s
ci / rust (pull_request) Successful in 15m55s
nix / flake (pull_request) Failing after 16m33s
ci / bun-nix (pull_request) Successful in 32s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m37s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 5m50s
android / android (pull_request) Successful in 7m56s
ci / rust (pull_request) Successful in 15m55s
nix / flake (pull_request) Failing after 16m33s
On a Deck in Gaming Mode the Steam menu and the QAM are driven by the SAME physical controller the client forwards, so opening either one moved the game on the host as well as Steam's UI — a second, invisible player. Steam Input masks a normal game here; it cannot mask us, because masking happens on Steam Input's virtual pad and we deliberately forward the REAL one (28DE:1205 — the virtual pad has no gyro, trackpads or paddles). SDL ships the exact behaviour we want and it is on by default: presses are dropped while the process has windows but no keyboard focus, releases still get through. It CANNOT fire on a Deck. gamescope resolves focus per Xwayland ctx and the client sits alone in its own, so the Steam overlay — which lives in the root ctx — never takes our X focus away and no FocusOut is ever generated. Measured on glass: with the QAM open, X input focus inside the client's ctx stayed on its window for the whole 4 s, while GAMESCOPE_FOCUSED_APP flipped to 769 (Steam) and GAMESCOPE_FOCUSED_APP_GFX stayed on the app. So the signal is explicit. `overlay_focus` watches those two atoms on the gamescope root ctx — which is NOT our own $DISPLAY under `--xwayland-count 2`, hence the socket-directory walk and the flatpak filesystem line — and the presenter ORs it with window focus into one `set_masked`. Masking is deliberately not `set_forwarding`: that closes the slot and sends GamepadRemove, so the game would see a controller UNPLUG every time somebody opened the QAM. This keeps every slot open and only stops the transitions, after flushing what the host believes is held so a stick deflected at overlay-open stops steering instead of freezing at its last value. On the way back, held buttons are adopted rather than replayed — the A that picked a QAM row must not fire in the game as it closes — while axes are re-sent, since a stick has no press to ghost and SDL only speaks on change. Fails open throughout: no gamescope, no X, or an unreadable signal all leave forwarding exactly as it was. `PUNKTFUNK_OVERLAY_MASK=0` opts out.
This commit is contained in:
Generated
+1
@@ -3047,6 +3047,7 @@ dependencies = [
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<MenuEvent>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<AtomicBool>,
|
||||
}
|
||||
|
||||
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<OverlayFocus> {
|
||||
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<String> {
|
||||
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<String> = entries
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().into_string().ok()?;
|
||||
let n = name.strip_prefix('X')?;
|
||||
n.parse::<u32>().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<u32> {
|
||||
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<u32>, gfx: Option<u32>) -> 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<AtomicBool>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -646,6 +646,18 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// 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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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.
|
||||
|
||||
@@ -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/
|
||||
|
||||
Reference in New Issue
Block a user