//! Low-level keyboard hook for capturing system key combinations. //! //! `WH_KEYBOARD_LL` is a GLOBAL hook: the OS invokes it for ALL desktop input regardless //! of which window is focused. We therefore gate diversion on the viewer's focus state. //! ONLY when the viewer window actually has focus AND "send system keys to remote" is //! enabled does the hook DIVERT the system combinations the local shell would otherwise //! consume — the Windows key, Win+R, Win+E, Alt+Tab, Ctrl+Esc, Alt+Esc — and forward them //! to the remote as full-fidelity `KeyEvent`s (virtual key + hardware scan code + //! extended-key flag + modifier snapshot), returning 1 from the hook proc to suppress the //! local handling. All other keys flow through the normal viewer input path. //! //! When the toggle is OFF, the viewer is not focused, or the key is not a system combo, //! the hook diverts NOTHING — it falls through to `CallNextHookEx` and every key reaches //! the local OS unchanged. This keeps the technician's own Start menu / Alt+Tab working //! while the viewer sits unfocused in the background. use super::InputEvent; #[cfg(windows)] use crate::proto; use anyhow::Result; use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::mpsc; #[cfg(windows)] use tracing::trace; #[cfg(windows)] use windows::{ Win32::Foundation::{LPARAM, LRESULT, WPARAM}, Win32::UI::WindowsAndMessaging::{ CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, LLKHF_EXTENDED, WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP, }, }; #[cfg(windows)] use std::sync::OnceLock; /// Global toggle: when `true`, system key combinations are diverted to the remote; /// when `false`, the hook is transparent and the local OS handles them. Default ON. /// /// Lives at module scope because the `WH_KEYBOARD_LL` callback is a bare `extern "system"` /// function with no user context pointer, so its state must be reachable statically. static SEND_SYSTEM_KEYS: AtomicBool = AtomicBool::new(true); /// Set whether system key combinations are forwarded to the remote (vs. handled locally). /// /// Part of the programmatic toggle API (alongside `toggle_send_system_keys`, which the /// Pause/Break host key drives). Retained for a future viewer menu / tray item and used /// by the unit tests; not yet called from non-test code, hence the allow. #[allow(dead_code)] pub fn set_send_system_keys(enabled: bool) { SEND_SYSTEM_KEYS.store(enabled, Ordering::Relaxed); } /// Flip the "send system keys to remote" toggle and return the new value. pub fn toggle_send_system_keys() -> bool { // fetch_xor(true) flips the bit and returns the PREVIOUS value; invert for the new one. !SEND_SYSTEM_KEYS.fetch_xor(true, Ordering::Relaxed) } /// Current state of the "send system keys to remote" toggle. /// /// Part of the programmatic toggle API; used by the unit tests and available for a /// viewer menu / status indicator. Not yet read from non-test code, hence the allow. #[allow(dead_code)] pub fn send_system_keys_enabled() -> bool { SEND_SYSTEM_KEYS.load(Ordering::Relaxed) } /// Whether the viewer window currently has input focus. Default `false`. /// /// `WH_KEYBOARD_LL` is a GLOBAL hook fired for all desktop input, so it must NOT divert /// system combos while the viewer is unfocused — otherwise the technician's own local /// Win key / Alt+Tab / Ctrl+Esc would be suppressed and pushed to the remote. The render /// loop updates this on `WindowEvent::Focused`. Lives at module scope for the same reason /// as `SEND_SYSTEM_KEYS`: the bare `extern "system"` callback has no user-context pointer. static VIEWER_FOCUSED: AtomicBool = AtomicBool::new(false); /// Record whether the viewer window has input focus (drives the hook's focus gate). pub fn set_viewer_focused(focused: bool) { VIEWER_FOCUSED.store(focused, Ordering::Relaxed); } /// Current focus state as seen by the keyboard hook. /// /// Used by the unit tests and available for diagnostics; not yet read from non-test code /// beyond the hook callback itself, hence the allow. #[allow(dead_code)] pub fn viewer_focused() -> bool { VIEWER_FOCUSED.load(Ordering::Relaxed) } #[cfg(windows)] static INPUT_TX: OnceLock> = OnceLock::new(); #[cfg(windows)] static mut HOOK_HANDLE: HHOOK = HHOOK(std::ptr::null_mut()); /// Virtual key codes for keys the hook reasons about. #[cfg(windows)] mod vk { pub const VK_LWIN: u32 = 0x5B; pub const VK_RWIN: u32 = 0x5C; pub const VK_APPS: u32 = 0x5D; pub const VK_TAB: u32 = 0x09; pub const VK_ESCAPE: u32 = 0x1B; } #[cfg(windows)] pub struct KeyboardHook { _hook: HHOOK, } #[cfg(windows)] impl KeyboardHook { pub fn new(input_tx: mpsc::Sender) -> Result { // Store the sender globally for the hook callback. If it was already set (e.g. // a previous viewer instance in the same process), reuse the existing one rather // than failing — the hook handle itself is what we re-install. let _ = INPUT_TX.set(input_tx); unsafe { let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_hook_proc), None, 0)?; HOOK_HANDLE = hook; Ok(Self { _hook: hook }) } } } #[cfg(windows)] impl Drop for KeyboardHook { fn drop(&mut self) { unsafe { if !HOOK_HANDLE.0.is_null() { let _ = UnhookWindowsHookEx(HOOK_HANDLE); HOOK_HANDLE = HHOOK(std::ptr::null_mut()); } } } } /// Decide whether a key event is a SYSTEM combination we must divert to the remote. /// /// `vk_code` is the key; `alt`/`ctrl` are the modifier state at the moment of the event /// (from `GetAsyncKeyState`). The Windows-key combos (Win, Win+R, Win+E) are recognized /// by matching the Win keys themselves, so the held-Win state is not needed here. Pure /// functions like this keep the (untestable) hook callback thin and unit-testable. #[cfg(windows)] fn is_system_combo(vk_code: u32, alt: bool, ctrl: bool) -> bool { match vk_code { // The Windows keys and the Applications (context-menu) key: always divert so the // local Start menu / Win+R / Win+E / Win+E etc. do not fire. With Win forwarded // down to the remote, subsequent letters (R, E, ...) compose there naturally. vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS => true, // Alt+Tab and Alt+Esc — the local window-switcher would otherwise eat these. vk::VK_TAB if alt => true, vk::VK_ESCAPE if alt => true, // Ctrl+Esc opens the local Start menu; divert it. vk::VK_ESCAPE if ctrl => true, _ => false, } } #[cfg(windows)] unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { if code >= 0 { let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT); let vk_code = kb_struct.vkCode; let scan_code = kb_struct.scanCode; // LLKHF_EXTENDED (bit 0) marks extended keys (right Ctrl/Alt, arrows, etc.). let is_extended = (kb_struct.flags.0 & LLKHF_EXTENDED.0) != 0; let is_down = wparam.0 as u32 == WM_KEYDOWN || wparam.0 as u32 == WM_SYSKEYDOWN; let is_up = wparam.0 as u32 == WM_KEYUP || wparam.0 as u32 == WM_SYSKEYUP; if is_down || is_up { let forwarding = SEND_SYSTEM_KEYS.load(Ordering::Relaxed); let focused = VIEWER_FOCUSED.load(Ordering::Relaxed); let modifiers = current_modifiers(); // Divert ONLY a SYSTEM combo, ONLY while forwarding is enabled, and ONLY while // the viewer window has focus. This is a global hook, so without the focus gate // we would swallow the technician's own Win/Alt+Tab/Ctrl+Esc while the viewer // sits unfocused in the background. When any condition is false we fall through // to CallNextHookEx and suppress nothing — the local OS handles the key. Ordinary // keys are left to the normal winit viewer input path (they are NOT forwarded // here to avoid double-injection). let divert = forwarding && focused && is_system_combo(vk_code, modifiers.alt, modifiers.ctrl); if divert { if let Some(tx) = INPUT_TX.get() { let event = proto::KeyEvent { down: is_down, key_type: proto::KeyEventType::KeyVk as i32, vk_code, scan_code, unicode: String::new(), is_extended, modifiers: Some(modifiers), }; let _ = tx.try_send(InputEvent::Key(event)); trace!( "System-key hook diverted: vk={:#x} scan={} ext={} down={}", vk_code, scan_code, is_extended, is_down ); } // Suppress local handling of the diverted system combo. return LRESULT(1); } } } CallNextHookEx(HOOK_HANDLE, code, wparam, lparam) } #[cfg(windows)] fn current_modifiers() -> proto::Modifiers { use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState; unsafe { proto::Modifiers { ctrl: GetAsyncKeyState(0x11) < 0, // VK_CONTROL alt: GetAsyncKeyState(0x12) < 0, // VK_MENU shift: GetAsyncKeyState(0x10) < 0, // VK_SHIFT meta: GetAsyncKeyState(0x5B) < 0 || GetAsyncKeyState(0x5C) < 0, // VK_LWIN/RWIN caps_lock: GetAsyncKeyState(0x14) & 1 != 0, // VK_CAPITAL num_lock: GetAsyncKeyState(0x90) & 1 != 0, // VK_NUMLOCK } } } // Non-Windows stubs #[cfg(not(windows))] #[allow(dead_code)] pub struct KeyboardHook; #[cfg(not(windows))] #[allow(dead_code)] impl KeyboardHook { pub fn new(_input_tx: mpsc::Sender) -> Result { Ok(Self) } } #[cfg(test)] mod tests { use super::*; #[test] fn toggle_defaults_on_and_flips() { // Default is ON. set_send_system_keys(true); assert!(send_system_keys_enabled()); // Toggling flips and returns the NEW value. assert!(!toggle_send_system_keys()); assert!(!send_system_keys_enabled()); assert!(toggle_send_system_keys()); assert!(send_system_keys_enabled()); // Explicit set wins. set_send_system_keys(false); assert!(!send_system_keys_enabled()); set_send_system_keys(true); } #[test] fn viewer_focus_flag_defaults_off_and_tracks() { // The hook starts gated CLOSED (unfocused) so a background viewer never swallows // the technician's local system keys until it actually gains focus. set_viewer_focused(false); assert!(!viewer_focused()); set_viewer_focused(true); assert!(viewer_focused()); set_viewer_focused(false); assert!(!viewer_focused()); } #[cfg(windows)] #[test] fn win_keys_always_divert() { // Win / Apps keys divert regardless of modifier state. assert!(is_system_combo(vk::VK_LWIN, false, false)); assert!(is_system_combo(vk::VK_RWIN, false, false)); assert!(is_system_combo(vk::VK_APPS, false, false)); } #[cfg(windows)] #[test] fn alt_tab_and_alt_esc_divert_only_with_alt() { assert!(is_system_combo(vk::VK_TAB, true, false)); // Alt+Tab assert!(!is_system_combo(vk::VK_TAB, false, false)); // plain Tab -> local path assert!(is_system_combo(vk::VK_ESCAPE, true, false)); // Alt+Esc } #[cfg(windows)] #[test] fn ctrl_esc_diverts_only_with_ctrl() { assert!(is_system_combo(vk::VK_ESCAPE, false, true)); // Ctrl+Esc assert!(!is_system_combo(vk::VK_ESCAPE, false, false)); // plain Esc -> local path } #[cfg(windows)] #[test] fn ordinary_keys_never_divert() { // 'R' is NOT itself a "system combo" — Win was already diverted (and forwarded // down), so R flows through the normal viewer path and composes Win+R on the remote. assert!(!is_system_combo(0x52, false, false)); // 'R' assert!(!is_system_combo(0x41, false, false)); // 'A' assert!(!is_system_combo(vk::VK_TAB, false, true)); // Ctrl+Tab is app-level, not a shell combo } }