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.