All checks were successful
The native viewer's H.264 path (Task 7 first-cut, compile-verified only) never rendered a frame. Three stacked bugs, all confirmed via live loopback: 1. decoder: MF_E_NOTACCEPTING (0xC00D36B5) was treated as fatal and only one output was drained per call, so once the MFT filled it rejected every subsequent frame. decode() now returns Vec<DecodedFrame>, drains on back-pressure and retries the unconsumed sample, then drains all ready outputs. 2. decoder: the NV12 output type was hand-built and rejected by the MS H.264 decoder MFT (MF_E_TRANSFORM_TYPE_NOT_SET, 0xC00D6D60). It is now negotiated by enumerating GetOutputAvailableType on STREAM_CHANGE / TYPE_NOT_SET. 3. render: a manual pump_messages() in about_to_wait stole winit's own thread messages and froze the event loop after one iteration, so frames were never drained from the channel. Removed; winit's run_app pump already services the WH_KEYBOARD_LL hook. Validated on a 5070 loopback: 0 decode errors, frames decode/paint/present (present count 0 -> 1740). Reviewed (APPROVE-WITH-NITS); diagnostics stripped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
322 lines
12 KiB
Rust
322 lines
12 KiB
Rust
//! 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<mpsc::Sender<InputEvent>> = 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<InputEvent>) -> Result<Self> {
|
|
// 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<InputEvent>) -> Result<Self> {
|
|
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
|
|
}
|
|
}
|