From fd3c54bd43c348e45c5a07983a8058001344a3c1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 17:45:26 +0200 Subject: [PATCH] feat(client): "Capture system shortcuts" finally decides where Alt+Tab goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toggle has been stored, profileable and rendered in two settings UIs since profiles landed, and nothing read it. Windows grabbed the keyboard whenever input was captured, setting or no setting; Linux never grabbed at all, because the grab sat behind `#[cfg(windows)]` with a comment deferring the compositor story to "the shells" — which never picked it up. `Settings.inhibit_shortcuts` now reaches the presenter and gates the grab, on both platforms. SDL3 already maps `SDL_SetWindowKeyboardGrab` onto `zwp_keyboard_shortcuts_inhibit_manager_v1` on Wayland and `XGrabKeyboard` (plus `_XWAYLAND_MAY_GRAB_KEYBOARD`) on X11, so dropping the cfg is the Linux fix. Capture state still gates it, so releasing input hands the chords straight back, and the desktop mouse model never grabs. A compositor with no shortcuts-inhibit global says so once instead of failing silently — at debug under gamescope, which has no shortcuts to inhibit in the first place. Co-Authored-By: Claude Opus 5 (1M context) --- clients/decky/src/backend.ts | 4 + clients/session/src/console.rs | 9 +- clients/session/src/main.rs | 1 + crates/pf-client-core/src/trust.rs | 6 +- crates/pf-presenter/src/run.rs | 105 +++++++++++++++++----- docs-site/content/docs/client-settings.md | 15 ++-- docs-site/content/docs/input.md | 7 +- 7 files changed, 114 insertions(+), 33 deletions(-) diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index 890cd5dc..dd1fe78f 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -113,6 +113,10 @@ export interface StreamSettings { codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files) gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck" compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" + // Round-trips only — deliberately NOT offered as a row here. It decides whether the session + // grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no + // compositor shortcuts to inhibit and hands the focused window every key already. A toggle + // here would be a dead one. The desktop client's row still edits this same file. inhibit_shortcuts: boolean; mic_enabled: boolean; } diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 51aea1a2..7e7234bc 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -140,10 +140,10 @@ pub fn run(target: Option<&str>) -> u8 { let settings_at_start = trust::Settings::load(); // The console's window and its input models are built ONCE, from the global defaults, and // live across every launch — so the presentation-tier fields below (stats tier, touch and - // mouse model, match-window, render scale) are latched here and a per-host profile cannot - // move them in this mode. Everything the HOST is told (mode, bitrate, codec, audio, pad) is - // re-resolved per launch and does honor the binding. Closing that gap means rebuilding the - // presenter's models per launch — profiles P4 territory, not P0. + // mouse model, shortcut inhibit, match-window, render scale) are latched here and a per-host + // profile cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec, + // audio, pad) is re-resolved per launch and does honor the binding. Closing that gap means + // rebuilding the presenter's models per launch — profiles P4 territory, not P0. let latched_mouse = settings_at_start.mouse_mode(); // Request-access hand-off: the launch handler stamps this when it starts a delegated-approval @@ -168,6 +168,7 @@ pub fn run(target: Option<&str>) -> u8 { touch_mode: settings_at_start.touch_mode(), mouse_mode: settings_at_start.mouse_mode(), invert_scroll: settings_at_start.invert_scroll, + inhibit_shortcuts: settings_at_start.inhibit_shortcuts, json_status, on_connected: Some(Box::new(move |fingerprint: [u8; 32]| { let fp_hex = trust::hex(&fingerprint); diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 5d16a616..46dcef79 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -572,6 +572,7 @@ mod session_main { touch_mode: settings.touch_mode(), mouse_mode: settings.mouse_mode(), invert_scroll: settings.invert_scroll, + inhibit_shortcuts: settings.inhibit_shortcuts, json_status: true, on_connected: Some(Box::new(|fingerprint: [u8; 32]| { // This host's card carries the accent bar in the desktop client now. diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 1042c3c6..828d6c7b 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -719,7 +719,11 @@ pub struct Settings { /// capture — today's behavior. #[serde(default = "default_mouse_mode")] pub mouse_mode: String, - /// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured. + /// Send system chords (Alt+Tab, Super / the Windows key) to the host while input is + /// captured under the `capture` mouse model; off leaves them with the local shell. + /// Read at connect into the presenter's session opts, which turns it into an SDL + /// keyboard grab (a low-level hook on Windows, shortcuts-inhibit or `XGrabKeyboard` + /// on Linux). The `desktop` mouse model never grabs, whatever this says. pub inhibit_shortcuts: bool, /// Stream the default microphone to the host's virtual mic source. pub mic_enabled: bool, diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index ee4e062d..50c62fad 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -55,6 +55,11 @@ pub struct SessionOpts { pub mouse_mode: MouseMode, /// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]). pub invert_scroll: bool, + /// Send system chords (Alt+Tab, the Windows key / Super) to the host while input is + /// captured ([`Settings::inhibit_shortcuts`], default on). Off keeps them local — the + /// work profile that streams on a second screen and still Alt-Tabs here. Never applies + /// under the `desktop` mouse model, which is something you Alt-Tab *away* from. + pub inhibit_shortcuts: bool, /// Emit the `{"ready":true}` stdout line after the first presented frame. pub json_status: bool, /// Called once on `Connected` with the host's fingerprint (trust persistence is the @@ -505,6 +510,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result let mouse = sdl.mouse(); let mut fullscreen = opts.fullscreen; + // Latched for the loop's life, like the other input models: `opts` is borrowed mutably + // for its callbacks at several of the `apply_capture` sites. + let inhibit_shortcuts = opts.inhibit_shortcuts; let mut stats_verbosity = opts.stats_verbosity; let mut overlay_frame: Option = None; // SDL text input tracks the overlay's editing state (started = IME/`TextInput` @@ -548,7 +556,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result WindowEvent::FocusLost => { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.release(false) { - apply_capture(&mut window, &mouse, false, false); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); tracing::info!("focus lost — input released"); } } @@ -559,7 +567,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.should_reengage() { cap.engage(); - apply_capture(&mut window, &mouse, true, cap.desktop()); + apply_capture( + &mut window, + &mouse, + true, + cap.desktop(), + inhibit_shortcuts, + ); tracing::info!("focus gained — input recaptured"); } } @@ -595,10 +609,16 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.captured() { cap.release(true); - apply_capture(&mut window, &mouse, false, false); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); } else { cap.engage(); - apply_capture(&mut window, &mouse, true, cap.desktop()); + apply_capture( + &mut window, + &mouse, + true, + cap.desktop(), + inhibit_shortcuts, + ); } tracing::info!(captured = cap.captured(), "chord: release/engage"); } @@ -613,7 +633,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result match cap.toggle_desktop() { Some(desktop) => { if cap.captured() { - apply_capture(&mut window, &mouse, true, desktop); + apply_capture( + &mut window, + &mouse, + true, + desktop, + inhibit_shortcuts, + ); } flipped = true; tracing::info!(desktop, "chord: mouse mode"); @@ -636,7 +662,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(st) = &mut stream { tracing::info!("chord: disconnect"); st.request_quit(); - apply_capture(&mut window, &mouse, false, false); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); // The pump emits Ended(None); the end path routes per mode. } continue; @@ -704,7 +730,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if !cap.captured() { // The engaging click is suppressed toward the host. cap.engage(); - apply_capture(&mut window, &mouse, true, cap.desktop()); + apply_capture( + &mut window, + &mouse, + true, + cap.desktop(), + inhibit_shortcuts, + ); } else { cap.on_button_down(mouse_btn); } @@ -851,7 +883,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(cap) = st.capture.as_mut() { // Desired model: hint ⇒ capture (desktop off); clear ⇒ desktop on. if cap.captured() && cap.set_desktop(!hint) { - apply_capture(&mut window, &mouse, true, cap.desktop()); + apply_capture( + &mut window, + &mouse, + true, + cap.desktop(), + inhibit_shortcuts, + ); if cap.desktop() { // Reappear where the host last had the pointer, so the // hand-back is seamless (Parsec's positionX/Y idea). @@ -894,7 +932,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result while escape_rx.try_recv().is_ok() { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.release(true) { - apply_capture(&mut window, &mouse, false, false); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); } } if fullscreen && !opts.fullscreen { @@ -907,7 +945,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(st) = &mut stream { tracing::info!("controller chord: disconnect"); st.request_quit(); - apply_capture(&mut window, &mouse, false, false); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); } } @@ -1017,7 +1055,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result abs_ok, ); cap.engage(); // capture engages when the stream starts (ui_stream parity) - apply_capture(&mut window, &mouse, true, cap.desktop()); + apply_capture(&mut window, &mouse, true, cap.desktop(), inhibit_shortcuts); st.capture = Some(cap); st.cursor_chan = Some(crate::cursor::CursorChannel::new(&c)); st.connector = Some(c); @@ -1070,7 +1108,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(st) = stream.take() { st.shutdown(); } - apply_capture(&mut window, &mouse, false, false); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); if let Some(o) = overlay.as_mut() { // A user-canceled dial ends silently — no error scene. if canceled { @@ -1087,7 +1125,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(cap) = &mut st.capture { cap.release(true); } - apply_capture(&mut window, &mouse, false, false); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); match &mode { ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)), ModeCtl::Browse(_) => { @@ -1676,11 +1714,17 @@ impl ResizeIndicator { } /// Apply the capture state to the window: pointer lock (relative mouse + hidden cursor) -/// and — on Windows — a keyboard grab, so system chords (Alt+Tab, the Windows key) reach -/// the host while captured instead of the local shell. SDL implements the grab there -/// with a low-level keyboard hook, the same mechanism the WinUI shell's in-process -/// client used its own WH_KEYBOARD_LL hooks for. Not engaged on Linux: the compositor -/// shortcut-inhibit story stays the shells' concern (Settings.inhibit_shortcuts). +/// and a keyboard grab, so system chords (Alt+Tab, the Windows key / Super) reach the +/// host while captured instead of the local shell. SDL implements the grab per platform: +/// a low-level keyboard hook on Windows (the same mechanism the WinUI shell's in-process +/// client used its own WH_KEYBOARD_LL hooks for), `zwp_keyboard_shortcuts_inhibit_manager_v1` +/// on Wayland, `XGrabKeyboard` (plus the `_XWAYLAND_MAY_GRAB_KEYBOARD` message under +/// XWayland) on X11. +/// +/// `inhibit` is [`Settings::inhibit_shortcuts`] — off leaves every system chord with the +/// local shell mid-stream, which is what a second-screen/work profile wants. It only ever +/// *removes* a grab: capture state still gates it, so releasing input (focus loss, the +/// Ctrl+Alt+Shift+Q chord, session end) always hands the chords back. /// /// The `desktop` mouse model never locks: the pointer roams (and leaves the window) /// freely, the local cursor is hidden over the window — the host's composited cursor, @@ -1692,11 +1736,32 @@ fn apply_capture( mouse: &sdl3::mouse::MouseUtil, on: bool, desktop: bool, + inhibit: bool, ) { mouse.set_relative_mouse_mode(window, on && !desktop); mouse.show_cursor(!on); - #[cfg(windows)] - window.set_keyboard_grab(on && !desktop); + let grab = on && !desktop && inhibit; + if !window.set_keyboard_grab(grab) && grab { + // The one refusal SDL reports is a missing mechanism — a Wayland compositor with no + // shortcuts-inhibit global. Said once per process: the answer never changes + // mid-session, and this runs on every engage. Under gamescope that is the expected + // shape, not a problem — it has no shortcuts of its own and hands the focused window + // every key already — so it stays at debug there rather than crying wolf once per + // Deck stream. + 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() { + tracing::debug!(error = %err, "no keyboard grab under gamescope — chords already ours"); + } else { + tracing::warn!( + error = %err, + "capture system shortcuts is on, but this compositor offers no way to grab \ + the keyboard — system chords stay with the local shell" + ); + } + } + } } /// Is this SDL touch device a real touchscreen (DIRECT, window-relative coordinates)? diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index 6876f003..ca1c3634 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -127,11 +127,16 @@ console home. Pinning one restricts the session to that controller alone — sin app has no such picker. **Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps only; Windows -spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. **It is not wired up yet.** The -value is stored, and a profile can even override it, but nothing in a running session reads it, so -the toggle changes nothing today. What actually happens is fixed: on Windows the session window -grabs the keyboard whenever input is captured in Capture mouse mode, so Alt+Tab and the Windows key -reach the host either way, and on Linux nothing inhibits the compositor's own shortcuts either way. +spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On, Alt+Tab and the Windows key +(Super on Linux) reach the host while the stream has input captured. Off, they act on this machine +instead — what you want when the stream shares a screen with local work. Either way the chords come +back the moment you release capture with **Ctrl+Alt+Shift+Q**, the window loses focus, or the stream +ends, and [Desktop mouse mode](/docs/input#mouse-modes) never takes them at all. Leaving this on does +mean **Ctrl+Alt+Shift+Q is your way out** of a captured stream, since Alt+Tab no longer is. + +On Linux this needs a compositor that supports keyboard-shortcuts-inhibit — KDE Plasma, GNOME and +the wlroots compositors all do, and X11 sessions grab the keyboard directly. Under +[gamescope](/docs/gamescope) there is nothing to inhibit: it hands the session everything already. **Invert scroll direction** — *default: off*, i.e. the host scrolls the way this machine does. diff --git a/docs-site/content/docs/input.md b/docs-site/content/docs/input.md index 86e0358c..64c04911 100644 --- a/docs-site/content/docs/input.md +++ b/docs-site/content/docs/input.md @@ -76,9 +76,10 @@ an Xbox pad), held on any connected pad. There are two, and they are a per-client setting called **Mouse input**: - **Capture (games)** — the pointer locks to the stream and only relative movement is sent. The only - cursor you see is the host's. This is what mouse-look in a game needs. On Windows the session - window also grabs the keyboard here, so Alt+Tab and the Windows key reach the host rather than your - own desktop. (The Linux session window does not do this.) + cursor you see is the host's. This is what mouse-look in a game needs. The session window also + grabs the keyboard here, so Alt+Tab and the Windows key (Super on Linux) reach the host rather than + your own desktop — turn **Capture system shortcuts** off in + [client settings](/docs/client-settings#input) to keep them local. - **Desktop (absolute)** — the pointer is not locked. It moves in and out of the stream freely and its position is sent as an absolute point — what you want for remote desktop work. Your local cursor is hidden over the stream; the one you see there is the host's.