Files
guru-connect/agent/src/viewer/input.rs
Mike Swanson 97780304e7
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m2s
Build and Test / Build Server (Linux) (push) Successful in 10m24s
Build and Test / Security Audit (push) Successful in 4m15s
Build and Test / Build Summary (push) Successful in 9s
fix(agent): make native H.264 viewer render live frames
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>
2026-05-31 11:25:05 -07:00

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
}
}