Files
chanora/crates/chanora_audio/src/ptt_backends/windows.rs
T
Edison Jwa 2f6d45fb04 feat(audio): desktop Silero ONNX VAD + Windows PTT modernization + MSVC CRT build fix (#37)
* feat(audio): add Silero ONNX VAD with WebRTC fallback

Introduce SileroOnnxVad and SileroOnnxVadWorker for desktop targets. The worker runs Silero v6 ONNX inference on a dedicated thread, accumulating 10 ms frames into the 512-sample 16 kHz input the model expects. Add VadOutput, VoiceActivityDetector trait, and WebRtcFallbackVad to provide a uniform VAD interface with graceful fallback when the ONNX model is unavailable. Wire the new VadBackend variants through AudioProcessingConfig and the snapshot stats so the bridge can report which detector is active.

* feat(audio): integrate desktop VAD worker into capture engine

Wire SileroOnnxVadWorker into the desktop capture path so voice activity can open the transmit gate before encoding. The capture callback now processes all audio through resample, downmix, and VAD unconditionally; transmit_active still gates Opus encoding.

Add new_desktop_audio_processing_state() to construct the config/stats/worker triple, and apply_desktop_vad_backend() to synchronously load or clear the worker on config changes. Override processing_backend to Noop for desktop so bridge diagnostics report the correct backend rather than the iOS-oriented PlatformVoiceProcessing default.

Includes review-driven cleanups: StreamConfig clone to deref per clippy, and a comment explaining why two try_lock calls on silero_vad_worker are structurally necessary (borrow checker requires the policy probe and the fallback path to not share a lock guard because mark_vad_fallback_active takes &mut self).

* fix(audio): modernize Windows PTT to current windows-rs API

Port the Raw Input plus low-level keyboard hook PTT backend to the newer windows-rs patterns: OptionalHandle, Result-returning CreateWindowExW, and None for CallNextHookEx. Replaces the old HHOOK(0) pointer casts. Add deterministic tests for mouse button 4 and 5 press and release driving the gate.

* build(windows): force MSVC release CRT for audiopus cmake builds

audiopus_sys calls cmake::build(opus_path), so downstream Cargo env cannot use cmake-rs Config::define() to override CMake's MSVC Debug CRT defaults. Point cmake-rs at a small wrapper that injects the policy and cache variables during configure while passing cmake --build, --version, and -E through unchanged. Keeps Opus Debug builds on Rust's release dynamic CRT (/MD) instead of CMake's default debug CRT (/MDd), which otherwise pulls in unresolved __imp__CrtDbgReportW symbols at test link.

Document that the iOS deployment target is intentionally absent from this file. It is enforced by tools/build-ios.sh and the Xcode project; setting it globally here would make native macOS cargo check runs try to link iPhone objects against the macOS SDK.

* build(flutter): update pubspec.lock after plugin additions

Regenerated lockfile reflecting the local_notifications and connectivity_plus plugin additions from the poke-notifications feature.

* fix(audio): address PR #37 review findings

Six fixes from independent PR review:

1. BLOCKER: Replace Windows-only cmake .cmd wrapper with cross-platform
   CMake env vars. Setting CMAKE=tools/cmake-msvc-release-crt.cmd
   globally broke non-Windows hosts because cmake-rs would try to
   execute a .cmd file on macOS/Linux. Instead, set
   CMAKE_POLICY_DEFAULT_CMP0091=NEW and CMAKE_MSVC_RUNTIME_LIBRARY=
   MultiThreadedDLL as env vars that CMake reads natively. MSVC-
   specific vars are safely ignored by GCC/Clang toolchains. Delete
   the now-unnecessary wrapper script.

2. IMPORTANT: Join the Silero worker thread in Drop instead of
   detaching it. The old code dropped the JoinHandle which detaches
   the thread; the new code calls handle.join() after closing the
   channel, ensuring the ONNX session is cleaned up before the
   worker is replaced during config changes.

3. IMPORTANT: Single-try_lock refactor of the capture VAD callback.
   The double try_lock (policy probe + send) is replaced by a single
   scoped try_lock that both probes availability and sends the frame.
   The guard is dropped before the fallback path, which needs &mut
   self for mark_vad_fallback_active. This also eliminates the
   VadWorkerPolicy enum and callback_vad_worker_policy function,
   whose behavior is now inlined into the callback.

4. IMPORTANT: Remove tracing from the realtime capture callback.
   mark_vad_fallback_active and sync_vad_backend emitted info!/warn!
   from the audio thread. Replace with silent atomic state
   publishing via SharedAudioProcessingStats; the bridge stats
   stream already exposes vad_fallback_active for diagnostics.

5. IMPORTANT: Defer ONNX model load outside the worker mutex.
   apply_desktop_vad_backend_to_worker now constructs the new worker
   before taking the lock, then swaps it in under a short hold.
   This prevents the realtime callback from being blocked during
   model I/O + thread spawn.

6. MINOR: Remove unused VadBackend import from vad/mod.rs after
   deleting the policy code.

* fix(audio): address PR #37 second-pass review findings

5-agent review found 5 blocking issues. All addressed:

1. BLOCKER: CMake env vars don't reach CMake cache. Restored .cmd wrapper
   but scoped to Windows MSVC targets only via [target.x86_64-pc-windows-msvc]
   and [target.aarch64-pc-windows-msvc] in .cargo/config.toml. Non-Windows
   hosts are unaffected.

2. BLOCKER: processing_backend normalized in set_audio_processing_config
   on desktop (cfg-gated override to Noop), mirroring startup default.

3. BLOCKER: Model-path reload was already wired via reload_audio_processing_config.
   Fixed misleading doc comment in core/lib.rs.

4. BLOCKER: DEC-030 updated to reflect desktop VoiceActivity enablement.
   Traceability docs (SRS, SysDes, SAD, SDD, implementation-status) updated.

5. Silero ONNX cfg narrowed to desktop-only (excludes macOS/Android).
   Cargo.toml ort dependency target cfg narrowed similarly.

6. Realtime callback debt documented as TODO at CaptureState::ingest.

* fix(audio): exclude ort dep on Android target

ort does not provide first-class Android prebuilts in our pin, mirror the
iOS/macOS exclusion so cargo metadata succeeds for android targets.

* test(audio): fix stale select_ptt_backend import in ptt_privacy

The helper moved out of the ptt_backends submodule onto the crate root;
update the integration test imports so the test compiles again.

* build(windows): scope MSVC release CRT cmake wrapper via Cargo [env]

Cargo's [target.<triple>] table only forwards a fixed allowlist
(linker, runner, rustflags, rustdocflags, ar), so setting CMAKE there
was silently dropped and audiopus_sys kept linking the debug CRT,
producing LNK4098 'MSVCRTD conflicts' and __imp__CrtDbgReportW errors
on x86_64-pc-windows-msvc test builds.

Move the override to Cargo's [env] table using cc/cmake-rs's
target-suffixed CMAKE_<triple> lookup (force=true, relative=true) so it
applies to MSVC targets only and not to host tooling. Add stdout
markers to the wrapper so its invocation is provable in cargo -vv logs.

Verified: cargo test -p chanora_audio --target x86_64-pc-windows-msvc
--lib --no-run now links cleanly; CMakeCache.txt records
CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL and CMP0091=NEW.

* fix(flutter): gate VoiceActivity transmit mode by platform support

VoiceActivity relies on the native VAD worker, which is only wired up
on Windows, Linux, and Android. Showing the option on iOS, macOS, or
web let users select a mode that silently never transmitted.

Add voiceActivityTransmitAvailable + transmitModeSegmentsFor() helpers
in voice_settings_controls.dart, hide the VAD row in voice_compact.dart
and drop the VAD segment from the settings dialog when unsupported.
Keep the legacy const transmitModeSegments for the existing widget test
and add two new tests covering the gated helper.
2026-06-09 20:47:16 +09:00

1670 lines
57 KiB
Rust

//! 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<Box<dyn DesktopPttBackend>> {
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<AtomicBinding>,
}
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<Option<RawInputContext>> =
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<AtomicBinding>,
gate: Option<AudioTransmitGate>,
/// Thread id of the message-loop worker. Used to post
/// `WM_QUIT` from `stop()` without needing a window handle.
worker_tid: Arc<AtomicIsize>,
worker: Option<thread::JoinHandle<()>>,
/// 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<AtomicBool>,
/// 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<Self> {
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::<bool>(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<bool>,
armed: Arc<AtomicBool>,
) -> 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::<WNDCLASSEXW>() 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::<RAWINPUTDEVICE>() 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::<RAWINPUTDEVICE>() 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::<RAWINPUTHEADER>() 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<Option<HookContext>> =
const { std::cell::RefCell::new(None) };
}
pub(crate) struct HookContext {
pub(crate) gate: AudioTransmitGate,
pub(crate) binding: Arc<AtomicBinding>,
}
/// 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<AtomicBinding>,
gate: Option<AudioTransmitGate>,
worker_tid: Arc<AtomicIsize>,
worker: Option<thread::JoinHandle<()>>,
armed: Arc<AtomicBool>,
is_mouse: AtomicBool,
}
impl WindowsHookBackend {
fn try_new() -> Option<Self> {
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::<bool>(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<bool>,
armed: Arc<AtomicBool>,
) -> 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<AtomicBinding> {
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::<RAWINPUT>()
}
#[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());
}
}