//! Windows desktop PTT backend (SDD-083 + SDD-084). //! //! Three-rung ladder per SAD-072: Raw Input first, low-level //! keyboard / mouse hook fallback, Focused PTT terminal fallback. //! The terminal fallback is handled by the cross-platform //! `select()` factory in the parent module returning `None` from //! `try_select`. //! //! Both Raw Input and the low-level hook need a dedicated OS //! thread because their callbacks fire on the thread that owns the //! message-only window / hook handle. The thread runs a standard //! Win32 GetMessageW/TranslateMessage/DispatchMessageW pump until //! `stop()` posts `WM_QUIT`. //! //! Privacy (DEC-027 / SDD-090): translation of the bound key is //! confined to the OS-callback hot path. The platform-neutral //! input class (`"keyboard"` / `"mouse-side-button"`) is the only //! identifier that ever crosses into a `tracing` record. The //! diagnostics sanitizer would drop a record carrying `vk` / //! `virtual_key` / `scan_code` even if we tried to log it, but //! this file never tries. use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; use std::sync::Arc; use std::thread; use tracing::{info, warn}; use windows::core::{w, PCWSTR}; use windows::Win32::Foundation::{HINSTANCE, HMODULE, HWND, LPARAM, LRESULT, WPARAM}; use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::UI::Input::{ GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, RAWINPUTHEADER, RIDEV_INPUTSINK, RIDEV_REMOVE, RID_INPUT, RIM_TYPEKEYBOARD, RIM_TYPEMOUSE, }; use windows::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL, WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1, XBUTTON2, }; use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding}; use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel}; mod keymap { pub use super::super::windows_keymap::*; } /// HWND_MESSAGE constant exposed as a raw isize. /// `windows-rs` defines it via a different module path across /// versions; use the documented integer directly. const HWND_MESSAGE_PTR: isize = -3; /// Try to construct the highest-capability Windows backend. The /// ladder evaluation is fixed for the lifetime of the engine; the /// engine's missed-key-up watchdog + Focused fallback compensate /// at runtime if a higher rung fails after `start`. pub fn try_select() -> Option> { if let Some(b) = WindowsRawInputBackend::try_new() { return Some(Box::new(b)); } if let Some(b) = WindowsHookBackend::try_new() { return Some(Box::new(b)); } None } /// Atomic mirror of the active binding. Shared between the public /// rebind path and the message-loop / hook thread that needs to /// read the binding on every key event without taking a lock. /// /// The fields are individually atomic so the callback path can /// snapshot them in a few cheap loads. `class` encodes the /// `PttInputClass` ordinal: 0 = None, 1 = Keyboard, 2 = MouseSideButton. /// `vk` holds the resolved Windows VK_* code when class==Keyboard; /// `mouse_btn` holds 4 (XBUTTON1) or 5 (XBUTTON2) when class==MouseSideButton. #[derive(Default)] pub(crate) struct AtomicBinding { class: AtomicIsize, vk: AtomicIsize, mouse_btn: AtomicIsize, } impl AtomicBinding { pub(crate) fn new() -> Self { Self::default() } pub(crate) fn store(&self, class: u8, vk: u16, mouse_btn: u8) { // Order: write fields first, class last, so a concurrent // reader that sees class != 0 also sees a coherent // vk / mouse_btn pair. Acquire/release rather than seqcst // because we have no other synchronisation around this. self.vk.store(vk as isize, Ordering::Release); self.mouse_btn.store(mouse_btn as isize, Ordering::Release); self.class.store(class as isize, Ordering::Release); } pub(crate) fn class(&self) -> u8 { self.class.load(Ordering::Acquire) as u8 } pub(crate) fn vk(&self) -> u16 { self.vk.load(Ordering::Acquire) as u16 } pub(crate) fn mouse_btn(&self) -> u8 { self.mouse_btn.load(Ordering::Acquire) as u8 } pub(crate) fn clear(&self) { self.class.store(0, Ordering::Release); self.vk.store(0, Ordering::Release); self.mouse_btn.store(0, Ordering::Release); } } /// Convert a `PttBinding` into the (class, vk, mouse_btn) triple. /// Returns `None` if the binding cannot be translated (caller /// surfaces `PttBackendError::InvalidBinding`). pub(crate) fn resolve_binding(binding: &PttBinding) -> Option<(u8, u16, u8)> { match binding.input_class { super::PttInputClass::None => Some((0, 0, 0)), super::PttInputClass::Keyboard => { let vk = keymap::key_label_to_vk(&binding.platform_key)?; Some((1, vk, 0)) } super::PttInputClass::MouseSideButton => { let idx = keymap::mouse_side_button_index(&binding.platform_key)?; Some((2, 0, idx)) } } } // ---------- Raw-Input backend ---------- /// Per-thread context for the Raw Input message-loop. Stored on /// the heap; the message window's `GWLP_USERDATA` is not used — /// the `WndProc` looks up its `RawInputContext` via a thread-local /// because Windows passes no user pointer on `WM_INPUT`. /// /// Exposed to the test module so unit tests can call /// [`dispatch_raw_input`] directly without spinning up a Win32 /// message pump (L0 refactor). pub(crate) struct RawInputContext { pub(crate) gate: AudioTransmitGate, pub(crate) binding: Arc, } thread_local! { /// Per-thread Raw-Input context. Set on entry to the worker /// thread; read by `raw_input_wnd_proc` on each `WM_INPUT`. static RAWINPUT_CTX: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } /// Raw-Input backend. Preferred Windows rung. Receives keyboard /// and mouse events globally (RIDEV_INPUTSINK) so the bound key /// fires even when Chanora is unfocused. pub struct WindowsRawInputBackend { binding: Arc, gate: Option, /// Thread id of the message-loop worker. Used to post /// `WM_QUIT` from `stop()` without needing a window handle. worker_tid: Arc, worker: Option>, /// True once the worker successfully registered the Raw Input /// devices. Reported via `descriptor()` so the UI only /// advertises Global capability when it's real. armed: Arc, /// True if mouse side-button was the most-recently-bound class. /// Cached so descriptor() can report L3 (mouse-button) vs L2 /// (keyboard) without re-reading the AtomicBinding. is_mouse: AtomicBool, } impl WindowsRawInputBackend { fn try_new() -> Option { info!( target: "chanora_audio", "windows ptt: selecting Raw Input backend (RIDEV_INPUTSINK)" ); Some(Self { binding: Arc::new(AtomicBinding::new()), gate: None, worker_tid: Arc::new(AtomicIsize::new(0)), worker: None, armed: Arc::new(AtomicBool::new(false)), is_mouse: AtomicBool::new(false), }) } } impl DesktopPttBackend for WindowsRawInputBackend { fn descriptor(&self) -> PttBackendDescriptor { // If we never armed, we are effectively running as a // Focused-equivalent placeholder; report L0 honestly so // the UI badge can surface the info-icon explanation // sheet (SDD-091). The cross-platform select() factory // still hands callers a WindowsRawInputBackend instance // because `try_select` does not probe — runtime arm is // where we learn if the platform accepts the registration. let level = if !self.armed.load(Ordering::Acquire) { PttCapabilityLevel::L0Focused } else if self.is_mouse.load(Ordering::Acquire) { PttCapabilityLevel::L3GlobalWithMouseButtons } else { PttCapabilityLevel::L2GlobalHoldToTalk }; let class = self.binding.class(); PttBackendDescriptor { level, backend_id: "raw-input", bound_input_class: match class { 0 => None, 2 => Some("mouse-side-button"), _ => Some("keyboard"), }, } } fn start( &mut self, gate: AudioTransmitGate, binding: PttBinding, ) -> Result<(), PttBackendError> { // Resolve binding before spawning so an invalid binding // surfaces synchronously to the caller. PttBinding::none() // resolves to class=0 (no firing); a malformed // platform_key surfaces as InvalidBinding. let resolved = resolve_binding(&binding).ok_or_else(|| { PttBackendError::InvalidBinding(format!( "windows raw-input cannot resolve binding (class={:?})", binding.input_class )) })?; self.binding.store(resolved.0, resolved.1, resolved.2); self.is_mouse.store(resolved.0 == 2, Ordering::Release); self.gate = Some(gate.clone()); let binding_for_worker = self.binding.clone(); let worker_tid = self.worker_tid.clone(); let armed = self.armed.clone(); let gate_for_worker = gate.clone(); let (init_tx, init_rx) = std::sync::mpsc::sync_channel::(1); let handle = thread::Builder::new() .name("chanora-rawinput".into()) .spawn(move || { // Stash our thread id so stop() can post WM_QUIT. let tid = unsafe { windows::Win32::System::Threading::GetCurrentThreadId() }; worker_tid.store(tid as isize, Ordering::Release); // Wire the per-thread context the WndProc reads // for gate access. RAWINPUT_CTX.with(|cell| { *cell.borrow_mut() = Some(RawInputContext { gate: gate_for_worker, binding: binding_for_worker, }); }); // Run the loop. Pass `init_tx` so the loop can // signal readiness AFTER RegisterRawInputDevices // succeeds but BEFORE GetMessageW starts blocking // — otherwise the main thread's readiness probe // times out (the signal would only fire when // WM_QUIT was eventually delivered). // // Also pass `armed` so the loop can flip the flag // to true inside the same critical window — the // outer `armed.store(ok, ...)` only runs after the // message pump returns (i.e. on WM_QUIT), which // never happens during normal arming. Without the // in-loop set, `descriptor()` would always report // L0Focused even though the backend is correctly // armed and receiving WM_INPUT events. let ok = unsafe { run_raw_input_loop(init_tx, armed.clone()) }; // Belt-and-braces: if the loop's normal exit // (WM_QUIT) happens before stop() runs the outer // set, clear the flag here too. if !ok { armed.store(false, Ordering::Release); } // If init failed, exit immediately. If it // succeeded, run_raw_input_loop already ran the // message pump and returned only when WM_QUIT // was received. // Clear per-thread context so any later thread // reusing the slot doesn't read stale state. RAWINPUT_CTX.with(|cell| { *cell.borrow_mut() = None; }); }) .map_err(|e| PttBackendError::Init(format!("rawinput thread: {e}")))?; // Wait briefly for the worker to report whether init // succeeded. The arm bool is the real source of truth; // this just lets us log accurately. match init_rx.recv_timeout(std::time::Duration::from_secs(2)) { Ok(true) => { // Success — the in-loop log line already wrote // "Raw Input devices registered" with full context. // Don't double-log; just continue. } Ok(false) => { warn!( target: "chanora_audio", "windows ptt: Raw Input registration failed; descriptor will report L0" ); } Err(_) => { warn!( target: "chanora_audio", "windows ptt: Raw Input init did not report within 2s; assuming failure" ); } } self.worker = Some(handle); Ok(()) } fn stop(&mut self) { let tid = self.worker_tid.load(Ordering::Acquire); if tid != 0 { unsafe { // Post WM_QUIT to the worker thread so its // GetMessageW loop returns. Ignore the result; // a stale tid just means the thread already // exited. let _ = PostThreadMessageW(tid as u32, WM_QUIT, WPARAM(0), LPARAM(0)); } } if let Some(h) = self.worker.take() { let _ = h.join(); } if let Some(g) = self.gate.take() { g.set(false); } self.armed.store(false, Ordering::Release); self.binding.clear(); } fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> { let resolved = resolve_binding(&binding).ok_or_else(|| { PttBackendError::InvalidBinding(format!( "windows raw-input cannot resolve binding (class={:?})", binding.input_class )) })?; self.binding.store(resolved.0, resolved.1, resolved.2); self.is_mouse.store(resolved.0 == 2, Ordering::Release); // Drop the current gate state on rebind: an in-flight // press on the prior key should not bleed into the new // binding's key-up logic. if let Some(g) = &self.gate { g.set(false); } Ok(()) } } impl Drop for WindowsRawInputBackend { fn drop(&mut self) { self.stop(); } } /// Run the Raw Input registration + message pump. Returns true if /// the registration succeeded and the loop terminated on WM_QUIT; /// false on any setup failure. /// /// # Safety /// Calls into Win32 directly. Must be invoked on the thread that /// owns the message window (created here). unsafe fn run_raw_input_loop( init_tx: std::sync::mpsc::SyncSender, armed: Arc, ) -> bool { // Helper that signals the readiness state to the main thread. // We send exactly once at the first decisive moment (either an // early-fail return or right after a successful // RegisterRawInputDevices). Subsequent sends are no-ops. let mut signal = Some(init_tx); macro_rules! report { ($v:expr) => { if let Some(tx) = signal.take() { let _ = tx.send($v); } }; } // Create a hidden message-only window. We need it as the // hwndTarget on the RAWINPUTDEVICE so RIDEV_INPUTSINK delivery // works even when our process has no visible window focus. let h_instance = match GetModuleHandleW(None) { Ok(h) => h, Err(e) => { warn!( target: "chanora_audio", error = %e, "windows ptt: GetModuleHandleW failed" ); report!(false); return false; } }; let class_name = w!("chanora_rawinput_msg"); let wc = WNDCLASSEXW { cbSize: std::mem::size_of::() as u32, lpfnWndProc: Some(raw_input_wnd_proc), hInstance: h_instance.into(), lpszClassName: class_name, ..Default::default() }; // Registration may fail with ERROR_CLASS_ALREADY_EXISTS if // start() ran before in this process; that's fine, we proceed // to CreateWindowExW which will succeed against the existing // class. let _atom = RegisterClassExW(&wc); let hwnd = match unsafe { CreateWindowExW( WINDOW_EX_STYLE(0), class_name, PCWSTR::null(), WINDOW_STYLE(0), 0, 0, 0, 0, Some(HWND(HWND_MESSAGE_PTR as *mut core::ffi::c_void)), None, Some(HINSTANCE(h_instance.0)), None, ) } { Ok(h) => h, Err(_) => { warn!( target: "chanora_audio", "windows ptt: CreateWindowExW(HWND_MESSAGE) returned null" ); report!(false); return false; } }; if hwnd.0.is_null() { warn!( target: "chanora_audio", "windows ptt: CreateWindowExW(HWND_MESSAGE) returned null" ); report!(false); return false; } // Register both keyboard and mouse, both with INPUTSINK so we // receive events even when unfocused. // Generic Desktop usage page = 0x01; keyboard usage = 0x06; // mouse usage = 0x02 (per HID spec). let devices = [ RAWINPUTDEVICE { usUsagePage: 0x01, usUsage: 0x06, dwFlags: RIDEV_INPUTSINK, hwndTarget: hwnd, }, RAWINPUTDEVICE { usUsagePage: 0x01, usUsage: 0x02, dwFlags: RIDEV_INPUTSINK, hwndTarget: hwnd, }, ]; let reg_ok = RegisterRawInputDevices(&devices, std::mem::size_of::() as u32); if reg_ok.is_err() { warn!( target: "chanora_audio", "windows ptt: RegisterRawInputDevices failed" ); report!(false); return false; } info!( target: "chanora_audio", "windows ptt: Raw Input devices registered (keyboard + mouse, INPUTSINK)" ); // Flip armed = true here, inside the loop, BEFORE blocking on // GetMessageW. The outer worker closure's armed.store(ok, ...) // only runs on WM_QUIT and so never fires during normal use. // Without this in-loop store, descriptor() would always report // L0Focused even though the backend is correctly receiving // WM_INPUT events — the bug that landed the capability badge // stuck at L0Focused on Korean Win 11 in TC-2.3. armed.store(true, Ordering::Release); // Signal readiness NOW (before we block on GetMessageW). The // main thread's init probe is waiting for this; the loop runs // until WM_QUIT and the return value at end-of-life is no // longer used as a readiness signal. report!(true); // Message pump. GetMessageW returns 0 on WM_QUIT, -1 on error. let mut msg = MSG::default(); loop { let r = GetMessageW(&mut msg, None, 0, 0).0; if r == 0 { // WM_QUIT. break; } if r == -1 { warn!(target: "chanora_audio", "windows ptt: GetMessageW returned -1"); break; } let _ = TranslateMessage(&msg); DispatchMessageW(&msg); } // Unregister cleanly so a fresh start() in this process // doesn't trip over a stale registration. let undo = [ RAWINPUTDEVICE { usUsagePage: 0x01, usUsage: 0x06, dwFlags: RIDEV_REMOVE, hwndTarget: HWND(std::ptr::null_mut()), }, RAWINPUTDEVICE { usUsagePage: 0x01, usUsage: 0x02, dwFlags: RIDEV_REMOVE, hwndTarget: HWND(std::ptr::null_mut()), }, ]; let _ = RegisterRawInputDevices(&undo, std::mem::size_of::() as u32); true } unsafe extern "system" fn raw_input_wnd_proc( hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM, ) -> LRESULT { if msg == WM_INPUT { handle_wm_input(lparam); } DefWindowProcW(hwnd, msg, wparam, lparam) } unsafe fn handle_wm_input(lparam: LPARAM) { let h_raw = HRAWINPUT(lparam.0 as *mut core::ffi::c_void); let mut size: u32 = 0; let header_sz = std::mem::size_of::() as u32; // First call: query buffer size. GetRawInputData(h_raw, RID_INPUT, None, &mut size, header_sz); if size == 0 { return; } let mut buf = vec![0u8; size as usize]; let got = GetRawInputData( h_raw, RID_INPUT, Some(buf.as_mut_ptr() as *mut _), &mut size, header_sz, ); if got != size { return; } let raw: &RAWINPUT = &*(buf.as_ptr() as *const RAWINPUT); RAWINPUT_CTX.with(|cell| { let borrow = cell.borrow(); if let Some(ctx) = borrow.as_ref() { dispatch_raw_input(ctx, raw); } }); } /// Pure-logic dispatcher for a decoded `RAWINPUT` message (L0 /// refactor; SDD-083 hot path). /// /// The WndProc unpacks the lparam via `GetRawInputData` and then /// calls into this helper. Tests construct a synthetic `RAWINPUT` /// (via `mem::zeroed` + field fill) and call this directly without /// any Win32 plumbing. /// /// Privacy: this function is the only place in the backend that /// touches `vk` / `usButtonFlags` integers; the comparison stays /// on the stack and the only side effect is `ctx.gate.set(bool)`. /// /// # Safety /// Reads from the `raw.data` union; the caller (the WndProc or a /// test) is responsible for the union being valid for the /// `header.dwType` it set. pub(crate) unsafe fn dispatch_raw_input(ctx: &RawInputContext, raw: &RAWINPUT) { let class = ctx.binding.class(); if class == 0 { return; } match raw.header.dwType { t if t == RIM_TYPEKEYBOARD.0 && class == 1 => { let kb = &raw.data.keyboard; let vk = ctx.binding.vk(); if kb.VKey == vk { // WM_KEY*DOWN messages have bit 0 of Flags clear; // WM_KEY*UP have bit 0 set (RI_KEY_BREAK = 1). if (kb.Flags & 0x01) == 0 { ctx.gate.set(true); } else { ctx.gate.set(false); } } } t if t == RIM_TYPEMOUSE.0 && class == 2 => { let m = &raw.data.mouse; let want = ctx.binding.mouse_btn(); let flags = m.Anonymous.Anonymous.usButtonFlags as u32; // RI_MOUSE_* constants per the Windows SDK. Hard-coded // here rather than imported because the `windows` // crate version in use only re-exports the // RIM_TYPE_* type tags from the same module. const RI_MOUSE_BUTTON_4_DOWN: u32 = 0x0040; const RI_MOUSE_BUTTON_4_UP: u32 = 0x0080; const RI_MOUSE_BUTTON_5_DOWN: u32 = 0x0100; const RI_MOUSE_BUTTON_5_UP: u32 = 0x0200; if want == 4 { if flags & RI_MOUSE_BUTTON_4_DOWN != 0 { ctx.gate.set(true); } else if flags & RI_MOUSE_BUTTON_4_UP != 0 { ctx.gate.set(false); } } else if want == 5 { if flags & RI_MOUSE_BUTTON_5_DOWN != 0 { ctx.gate.set(true); } else if flags & RI_MOUSE_BUTTON_5_UP != 0 { ctx.gate.set(false); } } } _ => {} } } // ---------- Low-level hook backend ---------- thread_local! { /// Per-thread Hook context. Set on entry to the hook worker /// thread; read by `kbd_hook_proc` and `mouse_hook_proc`. static HOOK_CTX: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } pub(crate) struct HookContext { pub(crate) gate: AudioTransmitGate, pub(crate) binding: Arc, } /// Low-level hook backend. Used only when Raw Input fails. /// Installs `WH_KEYBOARD_LL` + `WH_MOUSE_LL` global hooks; hook /// procs translate the event and drive the transmit gate. pub struct WindowsHookBackend { binding: Arc, gate: Option, worker_tid: Arc, worker: Option>, armed: Arc, is_mouse: AtomicBool, } impl WindowsHookBackend { fn try_new() -> Option { warn!( target: "chanora_audio", "windows ptt: falling back to low-level keyboard hook (WH_KEYBOARD_LL)" ); Some(Self { binding: Arc::new(AtomicBinding::new()), gate: None, worker_tid: Arc::new(AtomicIsize::new(0)), worker: None, armed: Arc::new(AtomicBool::new(false)), is_mouse: AtomicBool::new(false), }) } } impl DesktopPttBackend for WindowsHookBackend { fn descriptor(&self) -> PttBackendDescriptor { let level = if !self.armed.load(Ordering::Acquire) { PttCapabilityLevel::L0Focused } else if self.is_mouse.load(Ordering::Acquire) { PttCapabilityLevel::L3GlobalWithMouseButtons } else { PttCapabilityLevel::L2GlobalHoldToTalk }; let class = self.binding.class(); PttBackendDescriptor { level, backend_id: "low-level-hook", bound_input_class: match class { 0 => None, 2 => Some("mouse-side-button"), _ => Some("keyboard"), }, } } fn start( &mut self, gate: AudioTransmitGate, binding: PttBinding, ) -> Result<(), PttBackendError> { let resolved = resolve_binding(&binding).ok_or_else(|| { PttBackendError::InvalidBinding(format!( "windows hook cannot resolve binding (class={:?})", binding.input_class )) })?; self.binding.store(resolved.0, resolved.1, resolved.2); self.is_mouse.store(resolved.0 == 2, Ordering::Release); self.gate = Some(gate.clone()); let binding_for_worker = self.binding.clone(); let worker_tid = self.worker_tid.clone(); let armed = self.armed.clone(); let gate_for_worker = gate.clone(); let (init_tx, init_rx) = std::sync::mpsc::sync_channel::(1); let handle = thread::Builder::new() .name("chanora-llhook".into()) .spawn(move || { let tid = unsafe { windows::Win32::System::Threading::GetCurrentThreadId() }; worker_tid.store(tid as isize, Ordering::Release); HOOK_CTX.with(|cell| { *cell.borrow_mut() = Some(HookContext { gate: gate_for_worker, binding: binding_for_worker, }); }); let ok = unsafe { run_hook_loop(init_tx, armed.clone()) }; if !ok { armed.store(false, Ordering::Release); } HOOK_CTX.with(|cell| { *cell.borrow_mut() = None; }); }) .map_err(|e| PttBackendError::Init(format!("llhook thread: {e}")))?; match init_rx.recv_timeout(std::time::Duration::from_secs(2)) { Ok(true) => { // Success — the in-loop log line already wrote // "low-level hooks installed"; no need to repeat. } Ok(false) => warn!( target: "chanora_audio", "windows ptt: SetWindowsHookEx failed; descriptor will report L0" ), Err(_) => warn!( target: "chanora_audio", "windows ptt: hook init did not report within 2s; assuming failure" ), } self.worker = Some(handle); Ok(()) } fn stop(&mut self) { let tid = self.worker_tid.load(Ordering::Acquire); if tid != 0 { unsafe { let _ = PostThreadMessageW(tid as u32, WM_QUIT, WPARAM(0), LPARAM(0)); } } if let Some(h) = self.worker.take() { let _ = h.join(); } if let Some(g) = self.gate.take() { g.set(false); } self.armed.store(false, Ordering::Release); self.binding.clear(); } fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> { let resolved = resolve_binding(&binding).ok_or_else(|| { PttBackendError::InvalidBinding(format!( "windows hook cannot resolve binding (class={:?})", binding.input_class )) })?; self.binding.store(resolved.0, resolved.1, resolved.2); self.is_mouse.store(resolved.0 == 2, Ordering::Release); if let Some(g) = &self.gate { g.set(false); } Ok(()) } } impl Drop for WindowsHookBackend { fn drop(&mut self) { self.stop(); } } /// Install WH_KEYBOARD_LL + WH_MOUSE_LL and run a message loop /// until WM_QUIT. Returns true if both hooks installed and the /// loop ran; false on any installation failure. /// /// # Safety /// Calls Win32 directly; must run on the thread that owns the /// hook handles. unsafe fn run_hook_loop( init_tx: std::sync::mpsc::SyncSender, armed: Arc, ) -> bool { let mut signal = Some(init_tx); macro_rules! report { ($v:expr) => { if let Some(tx) = signal.take() { let _ = tx.send($v); } }; } let h_instance: HMODULE = match GetModuleHandleW(None) { Ok(h) => h, Err(e) => { warn!( target: "chanora_audio", error = %e, "windows ptt: GetModuleHandleW failed (hook)" ); report!(false); return false; } }; let kbd_proc: HOOKPROC = Some(kbd_hook_proc); let mouse_proc: HOOKPROC = Some(mouse_hook_proc); let kbd_hook = match SetWindowsHookExW(WH_KEYBOARD_LL, kbd_proc, Some(HINSTANCE(h_instance.0)), 0) { Ok(h) => h, Err(e) => { warn!( target: "chanora_audio", error = %e, "windows ptt: SetWindowsHookExW(WH_KEYBOARD_LL) failed" ); report!(false); return false; } }; let mouse_hook = match SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, Some(HINSTANCE(h_instance.0)), 0) { Ok(h) => h, Err(e) => { warn!( target: "chanora_audio", error = %e, "windows ptt: SetWindowsHookExW(WH_MOUSE_LL) failed" ); let _ = UnhookWindowsHookEx(kbd_hook); report!(false); return false; } }; info!( target: "chanora_audio", "windows ptt: low-level hooks installed (WH_KEYBOARD_LL + WH_MOUSE_LL)" ); // Flip armed = true here, before blocking on GetMessageW. // Same rationale as run_raw_input_loop. armed.store(true, Ordering::Release); // Signal readiness now, before blocking on GetMessageW. The // return value at end-of-loop is no longer used by the init // probe. report!(true); let mut msg = MSG::default(); loop { let r = GetMessageW(&mut msg, None, 0, 0).0; if r == 0 { break; } if r == -1 { warn!(target: "chanora_audio", "windows ptt: GetMessageW returned -1 (hook)"); break; } let _ = TranslateMessage(&msg); DispatchMessageW(&msg); } let _ = UnhookWindowsHookEx(kbd_hook); let _ = UnhookWindowsHookEx(mouse_hook); true } unsafe extern "system" fn kbd_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { if code == HC_ACTION as i32 { let kb = &*(lparam.0 as *const KBDLLHOOKSTRUCT); HOOK_CTX.with(|cell| { let borrow = cell.borrow(); if let Some(ctx) = borrow.as_ref() { dispatch_hook_keyboard(ctx, wparam, kb); } }); } CallNextHookEx(None, code, wparam, lparam) } /// Pure-logic dispatcher for a low-level keyboard hook event (L0 /// refactor; SDD-084 hot path). The hook proc unpacks the /// `KBDLLHOOKSTRUCT` from `lparam` then calls into this helper so /// the tests can exercise the press-edge translation without /// installing a global hook. pub(crate) fn dispatch_hook_keyboard(ctx: &HookContext, wparam: WPARAM, kb: &KBDLLHOOKSTRUCT) { if ctx.binding.class() != 1 { return; } if kb.vkCode as u16 != ctx.binding.vk() { return; } let w = wparam.0 as u32; let down = w == WM_KEYDOWN || w == WM_SYSKEYDOWN; let up = w == WM_KEYUP || w == WM_SYSKEYUP; if down { ctx.gate.set(true); } else if up { ctx.gate.set(false); } } unsafe extern "system" fn mouse_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { if code == HC_ACTION as i32 { let m = &*(lparam.0 as *const MSLLHOOKSTRUCT); HOOK_CTX.with(|cell| { let borrow = cell.borrow(); if let Some(ctx) = borrow.as_ref() { dispatch_hook_mouse(ctx, wparam, m); } }); } CallNextHookEx(None, code, wparam, lparam) } /// Pure-logic dispatcher for a low-level mouse hook event (L0 /// refactor; SDD-084 hot path). The mouse hook proc unpacks the /// `MSLLHOOKSTRUCT` from `lparam` then calls into this helper. pub(crate) fn dispatch_hook_mouse(ctx: &HookContext, wparam: WPARAM, m: &MSLLHOOKSTRUCT) { if ctx.binding.class() != 2 { return; } // For X-button messages mouseData high word distinguishes // XBUTTON1 (1) from XBUTTON2 (2). let xbutton = (m.mouseData >> 16) as u16; let want = ctx.binding.mouse_btn(); let want_xbutton = if want == 4 { XBUTTON1 } else if want == 5 { XBUTTON2 } else { 0 }; if xbutton != want_xbutton || want_xbutton == 0 { return; } match wparam.0 as u32 { WM_XBUTTONDOWN => ctx.gate.set(true), WM_XBUTTONUP => ctx.gate.set(false), _ => {} } } // ---------- Tests (Windows-only) ---------- // // These unit tests exercise the pure-logic dispatchers refactored // out of the WndProc / hook procs (L0). The dispatchers can be // called directly with a constructed `RawInputContext` / // `HookContext` and a synthetic `RAWINPUT` / `KBDLLHOOKSTRUCT` / // `MSLLHOOKSTRUCT` — no Win32 message pump is involved, so the // tests are deterministic and fast. #[cfg(test)] mod tests { use super::*; use crate::ptt_backends::PttInputClass; fn fresh_binding() -> Arc { Arc::new(AtomicBinding::new()) } // ---- L2: AtomicBinding ---- #[test] fn atomic_binding_default_is_all_zero() { let b = AtomicBinding::new(); assert_eq!(b.class(), 0); assert_eq!(b.vk(), 0); assert_eq!(b.mouse_btn(), 0); } #[test] fn atomic_binding_store_round_trip() { let b = AtomicBinding::new(); b.store(1, 0x42, 0); assert_eq!(b.class(), 1); assert_eq!(b.vk(), 0x42); assert_eq!(b.mouse_btn(), 0); b.store(2, 0, 5); assert_eq!(b.class(), 2); assert_eq!(b.vk(), 0); assert_eq!(b.mouse_btn(), 5); } #[test] fn atomic_binding_clear_resets_all_fields() { let b = AtomicBinding::new(); b.store(1, 0x42, 7); b.clear(); assert_eq!(b.class(), 0); assert_eq!(b.vk(), 0); assert_eq!(b.mouse_btn(), 0); } #[test] fn atomic_binding_u8_and_u16_no_torn_reads() { // Atomic semantics on individual fields preclude torn // reads. Smoke-check by toggling a non-zero value. let b = AtomicBinding::new(); for v in [0u16, 1, 0xFF, 0xFFFE, 0x7F00] { b.store(1, v, 0); assert_eq!(b.vk(), v); } } #[test] fn atomic_binding_concurrent_single_writer_single_reader() { use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; let b = Arc::new(AtomicBinding::new()); let stop = Arc::new(AtomicBool::new(false)); let bw = b.clone(); let stopw = stop.clone(); let writer = thread::spawn(move || { for i in 0..10_000u32 { if i % 2 == 0 { bw.store(1, 0x41, 0); } else { bw.store(2, 0, 5); } if stopw.load(Ordering::Relaxed) { break; } } }); let br = b.clone(); let stopr = stop.clone(); let reader = thread::spawn(move || { for _ in 0..10_000 { let c = br.class(); let vk = br.vk(); let m = br.mouse_btn(); // Coherence isn't guaranteed across the three // fields (each is independently atomic). Just // assert each is a sane u8/u16 value — the test // is for "no panic, no torn read". assert!(c <= 2); let _ = vk; let _ = m; if stopr.load(Ordering::Relaxed) { break; } } }); writer.join().unwrap(); stop.store(true, Ordering::Relaxed); reader.join().unwrap(); } #[test] fn atomic_binding_many_readers_single_writer() { use std::thread; let b = Arc::new(AtomicBinding::new()); b.store(1, 0x42, 0); let mut handles = Vec::new(); for _ in 0..4 { let br = b.clone(); handles.push(thread::spawn(move || { for _ in 0..5_000 { let c = br.class(); assert!(c <= 2); } })); } let bw = b.clone(); let writer = thread::spawn(move || { for i in 0..5_000u16 { bw.store(1, i, 0); } }); writer.join().unwrap(); for h in handles { h.join().unwrap(); } } // ---- L3: resolve_binding dispatcher ---- fn binding(class: PttInputClass, key: &str) -> PttBinding { PttBinding { input_class: class, platform_key: key.to_string(), } } #[test] fn resolve_binding_none_returns_zero_triple() { // PttInputClass::None is the "no binding" path. The // dispatcher returns Some((0,0,0)) so callers can store // the cleared values atomically without a separate // "not bound" branch. let r = resolve_binding(&binding(PttInputClass::None, "")); assert_eq!(r, Some((0, 0, 0))); } #[test] fn resolve_binding_keyboard_space() { let r = resolve_binding(&binding(PttInputClass::Keyboard, "Space")); assert_eq!(r, Some((1, 0x20, 0))); } #[test] fn resolve_binding_keyboard_letter_a() { let r = resolve_binding(&binding(PttInputClass::Keyboard, "A")); assert_eq!(r, Some((1, 0x41, 0))); } #[test] fn resolve_binding_keyboard_f10() { let r = resolve_binding(&binding(PttInputClass::Keyboard, "F10")); assert_eq!(r, Some((1, 0x79, 0))); } #[test] fn resolve_binding_keyboard_gibberish_returns_none() { let r = resolve_binding(&binding(PttInputClass::Keyboard, "GibberishKey")); assert_eq!(r, None); } #[test] fn resolve_binding_keyboard_empty_returns_none() { let r = resolve_binding(&binding(PttInputClass::Keyboard, "")); assert_eq!(r, None); } #[test] fn resolve_binding_mouse_back_button() { let r = resolve_binding(&binding( PttInputClass::MouseSideButton, "mouse-side-button:8", )); assert_eq!(r, Some((2, 0, 4))); } #[test] fn resolve_binding_mouse_forward_button() { let r = resolve_binding(&binding( PttInputClass::MouseSideButton, "mouse-side-button:16", )); assert_eq!(r, Some((2, 0, 5))); } #[test] fn resolve_binding_mouse_unknown_bitmask_returns_none() { let r = resolve_binding(&binding( PttInputClass::MouseSideButton, "mouse-side-button:99", )); assert_eq!(r, None); } #[test] fn resolve_binding_mouse_empty_returns_none() { let r = resolve_binding(&binding(PttInputClass::MouseSideButton, "")); assert_eq!(r, None); } #[test] fn resolve_binding_mismatched_class_and_key_returns_none() { // Keyboard class + mouse-side-button key string: the // keymap parses the string as a key label and finds no // match → None. let r = resolve_binding(&binding(PttInputClass::Keyboard, "mouse-side-button:8")); assert_eq!(r, None); // MouseSideButton class + plain key label: mouse-side // parser rejects strings without the prefix → None. let r = resolve_binding(&binding(PttInputClass::MouseSideButton, "Space")); assert_eq!(r, None); } // ---- L4: Backend state machine ---- #[test] fn raw_input_backend_descriptor_before_start_is_l0() { let b = WindowsRawInputBackend::try_new().expect("backend constructible"); let d = b.descriptor(); assert_eq!(d.level, PttCapabilityLevel::L0Focused); assert_eq!(d.backend_id, "raw-input"); assert_eq!(d.bound_input_class, None); } #[test] fn hook_backend_descriptor_before_start_is_l0() { let b = WindowsHookBackend::try_new().expect("backend constructible"); let d = b.descriptor(); assert_eq!(d.level, PttCapabilityLevel::L0Focused); assert_eq!(d.backend_id, "low-level-hook"); assert_eq!(d.bound_input_class, None); } #[test] fn raw_input_backend_descriptor_after_arm_keyboard_is_l2() { // Synthesise an "armed" state without actually starting // the message pump by writing the AtomicBool + binding // directly. The real `start()` path is exercised on the // Windows runtime under the #[ignore]'d test below. let b = WindowsRawInputBackend::try_new().unwrap(); b.binding.store(1, 0x42, 0); b.is_mouse.store(false, Ordering::Release); b.armed.store(true, Ordering::Release); let d = b.descriptor(); assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk); assert_eq!(d.bound_input_class, Some("keyboard")); } #[test] fn raw_input_backend_descriptor_after_arm_mouse_is_l3() { let b = WindowsRawInputBackend::try_new().unwrap(); b.binding.store(2, 0, 4); b.is_mouse.store(true, Ordering::Release); b.armed.store(true, Ordering::Release); let d = b.descriptor(); assert_eq!(d.level, PttCapabilityLevel::L3GlobalWithMouseButtons); assert_eq!(d.bound_input_class, Some("mouse-side-button")); } #[test] fn hook_backend_descriptor_after_arm_keyboard_is_l2() { let b = WindowsHookBackend::try_new().unwrap(); b.binding.store(1, 0x42, 0); b.is_mouse.store(false, Ordering::Release); b.armed.store(true, Ordering::Release); let d = b.descriptor(); assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk); assert_eq!(d.bound_input_class, Some("keyboard")); } #[test] fn hook_backend_descriptor_after_arm_mouse_is_l3() { let b = WindowsHookBackend::try_new().unwrap(); b.binding.store(2, 0, 5); b.is_mouse.store(true, Ordering::Release); b.armed.store(true, Ordering::Release); let d = b.descriptor(); assert_eq!(d.level, PttCapabilityLevel::L3GlobalWithMouseButtons); assert_eq!(d.bound_input_class, Some("mouse-side-button")); } #[test] fn raw_input_backend_start_rejects_invalid_binding() { let mut b = WindowsRawInputBackend::try_new().unwrap(); let gate = AudioTransmitGate::new(false); let bad = binding(PttInputClass::Keyboard, "Banana"); let r = b.start(gate, bad); match r { Err(PttBackendError::InvalidBinding(_)) => {} other => panic!("expected InvalidBinding, got {other:?}"), } } #[test] fn hook_backend_start_rejects_invalid_binding() { let mut b = WindowsHookBackend::try_new().unwrap(); let gate = AudioTransmitGate::new(false); let bad = binding(PttInputClass::Keyboard, "Banana"); let r = b.start(gate, bad); match r { Err(PttBackendError::InvalidBinding(_)) => {} other => panic!("expected InvalidBinding, got {other:?}"), } } #[test] fn raw_input_backend_rebind_to_none_leaves_class_zero() { let b = WindowsRawInputBackend::try_new().unwrap(); b.binding.store(1, 0x42, 0); // Simulate rebind to None by storing the resolved triple // directly (resolve_binding(None) yields (0,0,0)). let resolved = resolve_binding(&PttBinding::none()).unwrap(); b.binding.store(resolved.0, resolved.1, resolved.2); assert_eq!(b.binding.class(), 0); assert_eq!(b.binding.vk(), 0); assert_eq!(b.binding.mouse_btn(), 0); } #[test] fn raw_input_backend_stop_is_idempotent_when_never_started() { let mut b = WindowsRawInputBackend::try_new().unwrap(); b.stop(); b.stop(); assert!(!b.armed.load(Ordering::Acquire)); assert_eq!(b.binding.class(), 0); } #[test] fn hook_backend_stop_is_idempotent_when_never_started() { let mut b = WindowsHookBackend::try_new().unwrap(); b.stop(); b.stop(); assert!(!b.armed.load(Ordering::Acquire)); assert_eq!(b.binding.class(), 0); } /// Full start → descriptor → stop cycle on the real Windows /// runtime. Catches the regression where `armed` was only /// flipped after `WM_QUIT` (i.e. never during normal use), so /// `descriptor()` reported `L0Focused` even though the /// `RegisterRawInputDevices` call had succeeded. /// /// Not ignored — must run on every Windows test pass. #[test] fn raw_input_backend_start_flips_armed_to_l2() { let mut b = WindowsRawInputBackend::try_new().unwrap(); let gate = AudioTransmitGate::new(false); b.start(gate, binding(PttInputClass::Keyboard, "Space")) .expect("real RegisterRawInputDevices should succeed on a Windows desktop"); // The worker thread flips armed inside the message-pump // loop. Give it a tiny window to do so. std::thread::sleep(std::time::Duration::from_millis(80)); let d = b.descriptor(); assert_eq!( d.level, PttCapabilityLevel::L2GlobalHoldToTalk, "armed must flip to true while the loop is running" ); assert_eq!(d.backend_id, "raw-input"); assert_eq!(d.bound_input_class, Some("keyboard")); b.stop(); } /// Same as above but for the WH_KEYBOARD_LL / WH_MOUSE_LL hook /// backend. Catches the parallel armed-flag regression there. #[test] fn hook_backend_start_flips_armed_to_l2() { let mut b = WindowsHookBackend::try_new().unwrap(); let gate = AudioTransmitGate::new(false); b.start(gate, binding(PttInputClass::Keyboard, "Space")) .expect("real SetWindowsHookExW should succeed on a Windows desktop"); std::thread::sleep(std::time::Duration::from_millis(80)); let d = b.descriptor(); assert_eq!( d.level, PttCapabilityLevel::L2GlobalHoldToTalk, "armed must flip to true while the hook is running" ); assert_eq!(d.backend_id, "low-level-hook"); b.stop(); } // ---- L5: dispatch_raw_input ---- /// Construct a zero-filled RAWINPUT then fill the header and /// the keyboard / mouse union member as needed. unsafe fn zeroed_raw_input() -> RAWINPUT { std::mem::zeroed::() } #[test] fn dispatch_raw_input_keyboard_match_press_then_release_drives_gate() { let bind = fresh_binding(); bind.store(1, 0x41, 0); let gate = AudioTransmitGate::new(false); let ctx = RawInputContext { gate: gate.clone(), binding: bind, }; unsafe { let mut raw = zeroed_raw_input(); raw.header.dwType = RIM_TYPEKEYBOARD.0; raw.data.keyboard.VKey = 0x41; raw.data.keyboard.Flags = 0; // down dispatch_raw_input(&ctx, &raw); assert!(gate.load(), "down edge must set the gate"); raw.data.keyboard.Flags = 1; // up (RI_KEY_BREAK) dispatch_raw_input(&ctx, &raw); assert!(!gate.load(), "up edge must clear the gate"); } } #[test] fn dispatch_raw_input_keyboard_non_matching_vk_leaves_gate_unchanged() { let bind = fresh_binding(); bind.store(1, 0x41, 0); let gate = AudioTransmitGate::new(false); let ctx = RawInputContext { gate: gate.clone(), binding: bind, }; unsafe { let mut raw = zeroed_raw_input(); raw.header.dwType = RIM_TYPEKEYBOARD.0; raw.data.keyboard.VKey = 0x42; // bound is A, this is B raw.data.keyboard.Flags = 0; dispatch_raw_input(&ctx, &raw); assert!(!gate.load(), "non-matching key must not fire the gate"); } } #[test] fn dispatch_raw_input_no_binding_class_ignores_all_events() { let bind = fresh_binding(); // class=0 let gate = AudioTransmitGate::new(false); let ctx = RawInputContext { gate: gate.clone(), binding: bind, }; unsafe { let mut raw = zeroed_raw_input(); raw.header.dwType = RIM_TYPEKEYBOARD.0; raw.data.keyboard.VKey = 0x41; raw.data.keyboard.Flags = 0; dispatch_raw_input(&ctx, &raw); assert!(!gate.load()); } } #[test] fn dispatch_raw_input_mouse_button4_press_release_drives_gate() { let bind = fresh_binding(); bind.store(2, 0, 4); let gate = AudioTransmitGate::new(false); let ctx = RawInputContext { gate: gate.clone(), binding: bind, }; // RI_MOUSE_BUTTON_4_DOWN = 0x0040, _UP = 0x0080. unsafe { let mut raw = zeroed_raw_input(); raw.header.dwType = RIM_TYPEMOUSE.0; raw.data.mouse.Anonymous.Anonymous.usButtonFlags = 0x0040; dispatch_raw_input(&ctx, &raw); assert!(gate.load()); raw.data.mouse.Anonymous.Anonymous.usButtonFlags = 0x0080; dispatch_raw_input(&ctx, &raw); assert!(!gate.load()); } } #[test] fn dispatch_raw_input_mouse_button5_press_release_drives_gate() { let bind = fresh_binding(); bind.store(2, 0, 5); let gate = AudioTransmitGate::new(false); let ctx = RawInputContext { gate: gate.clone(), binding: bind, }; // RI_MOUSE_BUTTON_5_DOWN = 0x0100, _UP = 0x0200. unsafe { let mut raw = zeroed_raw_input(); raw.header.dwType = RIM_TYPEMOUSE.0; raw.data.mouse.Anonymous.Anonymous.usButtonFlags = 0x0100; dispatch_raw_input(&ctx, &raw); assert!(gate.load()); raw.data.mouse.Anonymous.Anonymous.usButtonFlags = 0x0200; dispatch_raw_input(&ctx, &raw); assert!(!gate.load()); } } #[test] fn dispatch_raw_input_mouse_button4_event_ignored_when_bound_to_button5() { let bind = fresh_binding(); bind.store(2, 0, 5); // bound to XBUTTON2 let gate = AudioTransmitGate::new(false); let ctx = RawInputContext { gate: gate.clone(), binding: bind, }; unsafe { let mut raw = zeroed_raw_input(); raw.header.dwType = RIM_TYPEMOUSE.0; raw.data.mouse.Anonymous.Anonymous.usButtonFlags = 0x0040; // BTN4 down dispatch_raw_input(&ctx, &raw); assert!(!gate.load()); raw.data.mouse.Anonymous.Anonymous.usButtonFlags = 0x0080; // BTN4 up dispatch_raw_input(&ctx, &raw); assert!(!gate.load()); } } #[test] fn dispatch_raw_input_hid_type_is_ignored() { // dwType = RIM_TYPEHID = 2 in the Win32 SDK. Use the // integer directly since the constant is not re-exported // from the `windows` crate's UI::Input module. const RIM_TYPEHID: u32 = 2; let bind = fresh_binding(); bind.store(1, 0x41, 0); let gate = AudioTransmitGate::new(false); let ctx = RawInputContext { gate: gate.clone(), binding: bind, }; unsafe { let mut raw = zeroed_raw_input(); raw.header.dwType = RIM_TYPEHID; dispatch_raw_input(&ctx, &raw); assert!(!gate.load()); } } // ---- L6: dispatch_hook_keyboard + dispatch_hook_mouse ---- fn zeroed_kbd() -> KBDLLHOOKSTRUCT { unsafe { std::mem::zeroed() } } fn zeroed_mouse() -> MSLLHOOKSTRUCT { unsafe { std::mem::zeroed() } } #[test] fn dispatch_hook_keyboard_match_keydown_then_keyup_drives_gate() { let bind = fresh_binding(); bind.store(1, 0x41, 0); let gate = AudioTransmitGate::new(false); let ctx = HookContext { gate: gate.clone(), binding: bind, }; let mut kb = zeroed_kbd(); kb.vkCode = 0x41; dispatch_hook_keyboard(&ctx, WPARAM(WM_KEYDOWN as usize), &kb); assert!(gate.load()); dispatch_hook_keyboard(&ctx, WPARAM(WM_KEYUP as usize), &kb); assert!(!gate.load()); } #[test] fn dispatch_hook_keyboard_match_syskeydown_and_syskeyup_drives_gate() { let bind = fresh_binding(); bind.store(1, 0x12, 0); // VK_MENU (Alt) let gate = AudioTransmitGate::new(false); let ctx = HookContext { gate: gate.clone(), binding: bind, }; let mut kb = zeroed_kbd(); kb.vkCode = 0x12; dispatch_hook_keyboard(&ctx, WPARAM(WM_SYSKEYDOWN as usize), &kb); assert!(gate.load()); dispatch_hook_keyboard(&ctx, WPARAM(WM_SYSKEYUP as usize), &kb); assert!(!gate.load()); } #[test] fn dispatch_hook_keyboard_non_matching_vk_leaves_gate_unchanged() { let bind = fresh_binding(); bind.store(1, 0x41, 0); let gate = AudioTransmitGate::new(false); let ctx = HookContext { gate: gate.clone(), binding: bind, }; let mut kb = zeroed_kbd(); kb.vkCode = 0x42; dispatch_hook_keyboard(&ctx, WPARAM(WM_KEYDOWN as usize), &kb); assert!(!gate.load()); } #[test] fn dispatch_hook_keyboard_ignored_when_class_is_not_keyboard() { let bind = fresh_binding(); bind.store(2, 0, 4); // mouse class let gate = AudioTransmitGate::new(false); let ctx = HookContext { gate: gate.clone(), binding: bind, }; let mut kb = zeroed_kbd(); kb.vkCode = 0x41; dispatch_hook_keyboard(&ctx, WPARAM(WM_KEYDOWN as usize), &kb); assert!(!gate.load()); } #[test] fn dispatch_hook_mouse_match_xbutton1_press_release_drives_gate() { let bind = fresh_binding(); bind.store(2, 0, 4); let gate = AudioTransmitGate::new(false); let ctx = HookContext { gate: gate.clone(), binding: bind, }; let mut m = zeroed_mouse(); // XBUTTON1 = 0x0001 in the high word of mouseData. m.mouseData = (XBUTTON1 as u32) << 16; dispatch_hook_mouse(&ctx, WPARAM(WM_XBUTTONDOWN as usize), &m); assert!(gate.load()); dispatch_hook_mouse(&ctx, WPARAM(WM_XBUTTONUP as usize), &m); assert!(!gate.load()); } #[test] fn dispatch_hook_mouse_match_xbutton2_press_release_drives_gate() { let bind = fresh_binding(); bind.store(2, 0, 5); let gate = AudioTransmitGate::new(false); let ctx = HookContext { gate: gate.clone(), binding: bind, }; let mut m = zeroed_mouse(); m.mouseData = (XBUTTON2 as u32) << 16; dispatch_hook_mouse(&ctx, WPARAM(WM_XBUTTONDOWN as usize), &m); assert!(gate.load()); dispatch_hook_mouse(&ctx, WPARAM(WM_XBUTTONUP as usize), &m); assert!(!gate.load()); } #[test] fn dispatch_hook_mouse_wrong_xbutton_is_ignored() { let bind = fresh_binding(); bind.store(2, 0, 4); // bound to XBUTTON1 let gate = AudioTransmitGate::new(false); let ctx = HookContext { gate: gate.clone(), binding: bind, }; let mut m = zeroed_mouse(); m.mouseData = (XBUTTON2 as u32) << 16; // wrong button dispatch_hook_mouse(&ctx, WPARAM(WM_XBUTTONDOWN as usize), &m); assert!(!gate.load()); } }