Opening the Steam menu on the Deck moved the game too — the pad is now held neutral while an overlay owns it #131
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