diff --git a/Cargo.lock b/Cargo.lock index bf356d4..ae069fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -388,6 +388,7 @@ dependencies = [ "tokio", "tracing", "tsclientlib", + "windows", "zbus", ] diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml index dff7a33..74537f4 100644 --- a/crates/chanora_audio/Cargo.toml +++ b/crates/chanora_audio/Cargo.toml @@ -34,6 +34,19 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } jni = { version = "0.21", default-features = false } ndk-context = "0.1" +[target.'cfg(target_os = "windows")'.dependencies] +# Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices +# + WM_INPUT translation backed by a hidden message-only window, and +# SetWindowsHookExW(WH_KEYBOARD_LL / WH_MOUSE_LL) fallback. Both +# require a per-backend OS thread that owns a message pump. +windows = { version = "0.54", features = [ + "Win32_Foundation", + "Win32_System_LibraryLoader", + "Win32_UI_Input", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_WindowsAndMessaging", +] } + [dev-dependencies] # `test-util` enables `start_paused` / virtual-clock tests used by # the missed-key-up watchdog unit tests. diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index d6e85e2..b071141 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -142,6 +142,11 @@ impl AudioEngine { mut voice_in_rx: mpsc::Receiver, ) -> Result { let host = cpal::default_host(); + info!( + target: "chanora_audio", + host_id = ?host.id(), + "starting audio engine: cpal host selected" + ); let in_dev = host .default_input_device() .ok_or(AudioError::NoInputDevice)?; @@ -156,6 +161,45 @@ impl AudioEngine { "starting audio engine" ); + // Log the cpal-reported default configs *before* trying to + // open streams, so a downstream stream-build failure can + // be cross-referenced against what the platform reported + // as its default format. Some locale / driver combinations + // on Windows have been observed to expose configs that + // accept device enumeration but reject `default_*_config` + // afterwards (reported on the ko-KR Windows 11 host as + // "Start audio button not work"). Promote what would + // otherwise be silent or laconic errors into structured + // log records the user can paste back. + match in_dev.default_input_config() { + Ok(c) => info!( + target: "chanora_audio", + channels = c.channels(), + sample_rate = c.sample_rate().0, + sample_format = ?c.sample_format(), + "default_input_config reported" + ), + Err(e) => warn!( + target: "chanora_audio", + error = %e, + "default_input_config FAILED — capture will be disabled" + ), + } + match out_dev.default_output_config() { + Ok(c) => info!( + target: "chanora_audio", + channels = c.channels(), + sample_rate = c.sample_rate().0, + sample_format = ?c.sample_format(), + "default_output_config reported" + ), + Err(e) => warn!( + target: "chanora_audio", + error = %e, + "default_output_config FAILED — output stream will fail to build" + ), + } + // A.5 mobile-only preset acknowledgement. On Linux desktop // the flag is ignored; on Android we log it so a future cpal // / Oboe wiring can be observed in the diagnostic export. @@ -702,7 +746,16 @@ where }, None, ) - .map_err(|e| AudioError::Backend(format!("build_output_stream: {e}")))?; + .map_err(|e| { + error!( + target: "chanora_audio", + error = %e, + requested_channels = config.channels, + requested_sample_rate = config.sample_rate.0, + "build_output_stream FAILED" + ); + AudioError::Backend(format!("build_output_stream: {e}")) + })?; Ok(stream) } diff --git a/crates/chanora_audio/src/ptt_backends/mod.rs b/crates/chanora_audio/src/ptt_backends/mod.rs index 6702318..f7eb8cf 100644 --- a/crates/chanora_audio/src/ptt_backends/mod.rs +++ b/crates/chanora_audio/src/ptt_backends/mod.rs @@ -27,6 +27,8 @@ mod focused; #[cfg(target_os = "windows")] mod windows; +#[cfg(target_os = "windows")] +mod windows_keymap; #[cfg(target_os = "macos")] mod macos; diff --git a/crates/chanora_audio/src/ptt_backends/windows.rs b/crates/chanora_audio/src/ptt_backends/windows.rs index fab69cd..dd566e4 100644 --- a/crates/chanora_audio/src/ptt_backends/windows.rs +++ b/crates/chanora_audio/src/ptt_backends/windows.rs @@ -8,26 +8,57 @@ //! //! 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. This file lays in the -//! scaffolding (`try_select` probe + descriptor reporting); the -//! live RawInput / SetWindowsHookEx wiring lands during platform -//! verification on a Windows reference host. The backends start -//! out reporting their target capability honestly through -//! `descriptor()` and store the binding for the diagnostic export. +//! 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, Ordering}; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; +use std::sync::{Arc, Mutex}; use std::thread; use tracing::{info, warn}; - -use super::{ - AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, +use windows::core::{w, PCWSTR}; +use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + XBUTTON1, XBUTTON2, }; +use windows::Win32::UI::Input::{ + GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, + RAWINPUTHEADER, RID_INPUT, RIDEV_INPUTSINK, RIDEV_REMOVE, RIM_TYPEKEYBOARD, RIM_TYPEMOUSE, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + CallNextHookEx, CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, + PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage, + UnhookWindowsHookEx, HC_ACTION, HHOOK, 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, +}; + +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. +/// 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)); @@ -38,46 +69,151 @@ pub fn try_select() -> Option> { None } -/// Raw-Input backend. Preferred Windows rung. +/// 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)] +struct AtomicBinding { + class: AtomicIsize, + vk: AtomicIsize, + mouse_btn: AtomicIsize, +} + +impl AtomicBinding { + fn new() -> Self { + Self::default() + } + + 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); + } + + fn class(&self) -> u8 { + self.class.load(Ordering::Acquire) as u8 + } + + fn vk(&self) -> u16 { + self.vk.load(Ordering::Acquire) as u16 + } + + fn mouse_btn(&self) -> u8 { + self.mouse_btn.load(Ordering::Acquire) as u8 + } + + 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`). +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`. +struct RawInputContext { + gate: AudioTransmitGate, + 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: PttBinding, + binding: Arc, gate: Option, - stop: Arc, + /// 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 { - // RegisterRawInputDevices probe lands in the platform- - // verification commit. For now we accept the rung - // optimistically; the watchdog + Focused fallback guard - // the user experience if it fails at runtime. info!( target: "chanora_audio", "windows ptt: selecting Raw Input backend (RIDEV_INPUTSINK)" ); Some(Self { - binding: PttBinding::none(), + binding: Arc::new(AtomicBinding::new()), gate: None, - stop: Arc::new(AtomicBool::new(false)), + 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: match self.binding.input_class { - super::PttInputClass::MouseSideButton => { - PttCapabilityLevel::L3GlobalWithMouseButtons - } - _ => PttCapabilityLevel::L2GlobalHoldToTalk, - }, + level, backend_id: "raw-input", - bound_input_class: match self.binding.class_str() { - "" => None, - "mouse-side-button" => Some("mouse-side-button"), + bound_input_class: match class { + 0 => None, + 2 => Some("mouse-side-button"), _ => Some("keyboard"), }, } @@ -88,42 +224,128 @@ impl DesktopPttBackend for WindowsRawInputBackend { 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()); - self.binding = binding.clone(); - let stop = self.stop.clone(); - // Spawn the Raw Input message loop on its own OS thread. - // The thread lives until `stop()` flips the AtomicBool. - // Live RegisterRawInputDevices + WndProc wiring lands - // during Windows platform verification; this scaffolding - // keeps the lifecycle correct so the rest of the system - // (descriptor, watchdog, capability event) is exercised. + + 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 || { - while !stop.load(Ordering::Relaxed) { - thread::sleep(std::time::Duration::from_millis(50)); - } + // 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, + }); + }); + + let ok = unsafe { run_raw_input_loop() }; + armed.store(ok, Ordering::Release); + let _ = init_tx.send(ok); + + // 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) => { + info!( + target: "chanora_audio", + bound_input_class = ?match self.binding.class() { + 2 => "mouse-side-button", + 1 => "keyboard", + _ => "none", + }, + "windows ptt: Raw Input armed" + ); + } + 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); - // Suppress unused-variable warning on the gate-clone we - // hold for the future live wiring. - let _ = &gate; Ok(()) } fn stop(&mut self) { - self.stop.store(true, Ordering::Relaxed); + 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> { - self.binding = binding; + 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(()) } } @@ -134,12 +356,255 @@ impl Drop for WindowsRawInputBackend { } } +/// 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() -> bool { + // 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" + ); + 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 CreateWindowExW( + WINDOW_EX_STYLE(0), + class_name, + PCWSTR::null(), + WINDOW_STYLE(0), + 0, + 0, + 0, + 0, + HWND(HWND_MESSAGE_PTR as *mut _), + None, + h_instance, + None, + ) { + Ok(h) => h, + Err(e) => { + warn!( + target: "chanora_audio", + error = %e, + "windows ptt: CreateWindowExW(HWND_MESSAGE) failed" + ); + 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" + ); + return false; + } + + info!( + target: "chanora_audio", + "windows ptt: Raw Input devices registered (keyboard + mouse, INPUTSINK)" + ); + + // 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 _); + 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(); + let Some(ctx) = borrow.as_ref() else { + return; + }; + 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(); + // usButtonFlags is a bitmask of RI_MOUSE_BUTTON_* + // constants; the X-button flags live in the + // u32 anonymous union under + // `m.Anonymous.Anonymous.usButtonFlags`. + let flags = m.Anonymous.Anonymous.usButtonFlags; + // Constants per Windows SDK + // (RawInput RI_MOUSE_*): + 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; + let flags = flags as u32; + 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) }; +} + +struct HookContext { + gate: AudioTransmitGate, + 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: PttBinding, + binding: Arc, gate: Option, - stop: Arc, + worker_tid: Arc, worker: Option>, + armed: Arc, + is_mouse: AtomicBool, } impl WindowsHookBackend { @@ -149,27 +614,32 @@ impl WindowsHookBackend { "windows ptt: falling back to low-level keyboard hook (WH_KEYBOARD_LL)" ); Some(Self { - binding: PttBinding::none(), + binding: Arc::new(AtomicBinding::new()), gate: None, - stop: Arc::new(AtomicBool::new(false)), + 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: match self.binding.input_class { - super::PttInputClass::MouseSideButton => { - PttCapabilityLevel::L3GlobalWithMouseButtons - } - _ => PttCapabilityLevel::L2GlobalHoldToTalk, - }, + level, backend_id: "low-level-hook", - bound_input_class: match self.binding.class_str() { - "" => None, - "mouse-side-button" => Some("mouse-side-button"), + bound_input_class: match class { + 0 => None, + 2 => Some("mouse-side-button"), _ => Some("keyboard"), }, } @@ -180,34 +650,93 @@ impl DesktopPttBackend for WindowsHookBackend { 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()); - self.binding = binding; - let stop = self.stop.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 || { - while !stop.load(Ordering::Relaxed) { - thread::sleep(std::time::Duration::from_millis(50)); - } + 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() }; + armed.store(ok, Ordering::Release); + let _ = init_tx.send(ok); + + 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) => info!( + target: "chanora_audio", + "windows ptt: low-level hook armed" + ), + 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); - let _ = &gate; Ok(()) } fn stop(&mut self) { - self.stop.store(true, Ordering::Relaxed); + 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> { - self.binding = binding; + 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(()) } } @@ -217,3 +746,129 @@ impl Drop for WindowsHookBackend { 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() -> bool { + let h_instance: HMODULE = match GetModuleHandleW(None) { + Ok(h) => h, + Err(e) => { + warn!( + target: "chanora_audio", + error = %e, + "windows ptt: GetModuleHandleW failed (hook)" + ); + 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, h_instance, 0) { + Ok(h) => h, + Err(e) => { + warn!( + target: "chanora_audio", + error = %e, + "windows ptt: SetWindowsHookExW(WH_KEYBOARD_LL) failed" + ); + return false; + } + }; + let mouse_hook = match SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, h_instance, 0) { + Ok(h) => h, + Err(e) => { + warn!( + target: "chanora_audio", + error = %e, + "windows ptt: SetWindowsHookExW(WH_MOUSE_LL) failed" + ); + let _ = UnhookWindowsHookEx(kbd_hook); + return false; + } + }; + + info!( + target: "chanora_audio", + "windows ptt: low-level hooks installed (WH_KEYBOARD_LL + WH_MOUSE_LL)" + ); + + 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() { + if ctx.binding.class() == 1 && kb.vkCode as u16 == ctx.binding.vk() { + let down = wparam.0 as u32 == WM_KEYDOWN + || wparam.0 as u32 == WM_SYSKEYDOWN; + let up = wparam.0 as u32 == WM_KEYUP + || wparam.0 as u32 == WM_SYSKEYUP; + if down { + ctx.gate.set(true); + } else if up { + ctx.gate.set(false); + } + } + } + }); + } + CallNextHookEx(HHOOK(std::ptr::null_mut()), code, wparam, lparam) +} + +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() { + if ctx.binding.class() == 2 { + // 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.0 + } else if want == 5 { + XBUTTON2.0 + } else { + 0 + }; + if xbutton == want_xbutton && want_xbutton != 0 { + match wparam.0 as u32 { + WM_XBUTTONDOWN => ctx.gate.set(true), + WM_XBUTTONUP => ctx.gate.set(false), + _ => {} + } + } + } + } + }); + } + CallNextHookEx(HHOOK(std::ptr::null_mut()), code, wparam, lparam) +} diff --git a/crates/chanora_audio/src/ptt_backends/windows_keymap.rs b/crates/chanora_audio/src/ptt_backends/windows_keymap.rs new file mode 100644 index 0000000..41823f7 --- /dev/null +++ b/crates/chanora_audio/src/ptt_backends/windows_keymap.rs @@ -0,0 +1,192 @@ +//! Translation of Flutter `LogicalKeyboardKey.keyLabel` strings to +//! Windows Virtual-Key codes (`VK_*`). +//! +//! The Flutter binding-capture dialog (`_PttBindingCaptureDialog` in +//! `apps/chanora_flutter/lib/main.dart`) emits the keyLabel into +//! `PttBinding.platform_key` (e.g. `"Space"`, `"F10"`, `"A"`, +//! `"Arrow Up"`). The bridge passes that string through unchanged. +//! On Windows the backends in `windows.rs` translate it to a +//! `VK_*` value here so RawInput and the low-level hook can match +//! against the bound key. +//! +//! Privacy: this module is the only place the platform_key string +//! is interpreted as a raw key value. The result (the integer +//! `VKEY`) **never** appears in any tracing record — it stays on +//! the stack inside the match-vs-binding hot path. The SDD-090 +//! `PttSanitizer` Layer would drop a record carrying `vk` / +//! `virtual_key` / `scan_code` even if we accidentally tried to +//! log it, but we should not rely on the safety net. + +use windows::Win32::UI::Input::KeyboardAndMouse as kbd; + +/// Translate a Flutter `LogicalKeyboardKey.keyLabel` string into a +/// Windows Virtual-Key code. Returns `None` for labels we do not +/// understand; the caller (the binding rebind path) surfaces those +/// as `PttBackendError::InvalidBinding` so the user can pick a +/// different key. +/// +/// The mapping table covers the common cases the binding-capture +/// dialog can surface on Western and East Asian keyboards. It is +/// intentionally a static lookup rather than a regex / parse: +/// platform-key strings are user-bound but Flutter-controlled, so +/// the set is small and stable per Flutter SDK. +pub fn key_label_to_vk(label: &str) -> Option { + // Single uppercase ASCII letter — Windows VKs for A..Z match + // their ASCII byte values (0x41..0x5A). + if label.len() == 1 { + let b = label.as_bytes()[0]; + if b.is_ascii_uppercase() { + return Some(b as u16); + } + if b.is_ascii_lowercase() { + return Some((b - b'a' + b'A') as u16); + } + if b.is_ascii_digit() { + return Some(b as u16); + } + } + + Some(match label { + // Whitespace + "Space" | " " => kbd::VK_SPACE.0, + "Enter" => kbd::VK_RETURN.0, + "Tab" => kbd::VK_TAB.0, + "Backspace" => kbd::VK_BACK.0, + "Escape" => kbd::VK_ESCAPE.0, + // Navigation cluster + "Arrow Up" | "ArrowUp" => kbd::VK_UP.0, + "Arrow Down" | "ArrowDown" => kbd::VK_DOWN.0, + "Arrow Left" | "ArrowLeft" => kbd::VK_LEFT.0, + "Arrow Right" | "ArrowRight" => kbd::VK_RIGHT.0, + "Home" => kbd::VK_HOME.0, + "End" => kbd::VK_END.0, + "Page Up" | "PageUp" => kbd::VK_PRIOR.0, + "Page Down" | "PageDown" => kbd::VK_NEXT.0, + "Insert" => kbd::VK_INSERT.0, + "Delete" => kbd::VK_DELETE.0, + // Modifiers — Flutter exposes left/right variants too. The + // binding-capture dialog deliberately filters bare modifier + // presses, but a user combining modifiers might still land + // here on the next key event. + "Shift" | "Shift Left" | "ShiftLeft" => kbd::VK_LSHIFT.0, + "Shift Right" | "ShiftRight" => kbd::VK_RSHIFT.0, + "Control" | "Control Left" | "ControlLeft" => kbd::VK_LCONTROL.0, + "Control Right" | "ControlRight" => kbd::VK_RCONTROL.0, + "Alt" | "Alt Left" | "AltLeft" => kbd::VK_LMENU.0, + "Alt Right" | "AltRight" => kbd::VK_RMENU.0, + "Meta" | "Meta Left" | "MetaLeft" => kbd::VK_LWIN.0, + "Meta Right" | "MetaRight" => kbd::VK_RWIN.0, + "Caps Lock" | "CapsLock" => kbd::VK_CAPITAL.0, + // F-keys + "F1" => kbd::VK_F1.0, + "F2" => kbd::VK_F2.0, + "F3" => kbd::VK_F3.0, + "F4" => kbd::VK_F4.0, + "F5" => kbd::VK_F5.0, + "F6" => kbd::VK_F6.0, + "F7" => kbd::VK_F7.0, + "F8" => kbd::VK_F8.0, + "F9" => kbd::VK_F9.0, + "F10" => kbd::VK_F10.0, + "F11" => kbd::VK_F11.0, + "F12" => kbd::VK_F12.0, + "F13" => kbd::VK_F13.0, + "F14" => kbd::VK_F14.0, + "F15" => kbd::VK_F15.0, + "F16" => kbd::VK_F16.0, + "F17" => kbd::VK_F17.0, + "F18" => kbd::VK_F18.0, + "F19" => kbd::VK_F19.0, + "F20" => kbd::VK_F20.0, + // Punctuation / OEM keys with stable VK assignments + ";" | "Semicolon" => kbd::VK_OEM_1.0, + "=" | "Equal" => kbd::VK_OEM_PLUS.0, + "," | "Comma" => kbd::VK_OEM_COMMA.0, + "-" | "Minus" => kbd::VK_OEM_MINUS.0, + "." | "Period" => kbd::VK_OEM_PERIOD.0, + "/" | "Slash" => kbd::VK_OEM_2.0, + "`" | "Backquote" => kbd::VK_OEM_3.0, + "[" | "BracketLeft" => kbd::VK_OEM_4.0, + "\\" | "Backslash" => kbd::VK_OEM_5.0, + "]" | "BracketRight" => kbd::VK_OEM_6.0, + "'" | "Quote" => kbd::VK_OEM_7.0, + // Numpad — Flutter labels these "Numpad " / "Numpad Enter" etc. + "Numpad 0" | "Numpad0" => kbd::VK_NUMPAD0.0, + "Numpad 1" | "Numpad1" => kbd::VK_NUMPAD1.0, + "Numpad 2" | "Numpad2" => kbd::VK_NUMPAD2.0, + "Numpad 3" | "Numpad3" => kbd::VK_NUMPAD3.0, + "Numpad 4" | "Numpad4" => kbd::VK_NUMPAD4.0, + "Numpad 5" | "Numpad5" => kbd::VK_NUMPAD5.0, + "Numpad 6" | "Numpad6" => kbd::VK_NUMPAD6.0, + "Numpad 7" | "Numpad7" => kbd::VK_NUMPAD7.0, + "Numpad 8" | "Numpad8" => kbd::VK_NUMPAD8.0, + "Numpad 9" | "Numpad9" => kbd::VK_NUMPAD9.0, + "Numpad Enter" | "NumpadEnter" => kbd::VK_RETURN.0, + "Numpad Multiply" | "NumpadMultiply" => kbd::VK_MULTIPLY.0, + "Numpad Add" | "NumpadAdd" => kbd::VK_ADD.0, + "Numpad Subtract" | "NumpadSubtract" => kbd::VK_SUBTRACT.0, + "Numpad Decimal" | "NumpadDecimal" => kbd::VK_DECIMAL.0, + "Numpad Divide" | "NumpadDivide" => kbd::VK_DIVIDE.0, + "Num Lock" | "NumLock" => kbd::VK_NUMLOCK.0, + // No match. + _ => return None, + }) +} + +/// Translate the platform-key string our binding-capture dialog +/// emits for mouse side-buttons (`"mouse-side-button:"`) +/// into a Windows Raw-Input mouse-button index in `[4, 5]`. +/// +/// Flutter's `PointerEvent.buttons` exposes back=0x08 and +/// forward=0x10; Windows Raw Input names them XBUTTON1 and XBUTTON2. +/// Returns `Some(4)` for back / XBUTTON1, `Some(5)` for forward / +/// XBUTTON2, or `None` if the platform_key string is malformed. +pub fn mouse_side_button_index(platform_key: &str) -> Option { + let suffix = platform_key.strip_prefix("mouse-side-button:")?; + let bitmask: u32 = suffix.parse().ok()?; + match bitmask { + 0x08 => Some(4), + 0x10 => Some(5), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ascii_letters_map_to_vk() { + assert_eq!(key_label_to_vk("A"), Some(0x41)); + assert_eq!(key_label_to_vk("Z"), Some(0x5A)); + // Lowercase normalised. + assert_eq!(key_label_to_vk("a"), Some(0x41)); + } + + #[test] + fn ascii_digits_map_to_vk() { + assert_eq!(key_label_to_vk("0"), Some(0x30)); + assert_eq!(key_label_to_vk("9"), Some(0x39)); + } + + #[test] + fn space_and_function_keys() { + assert_eq!(key_label_to_vk("Space"), Some(0x20)); + assert_eq!(key_label_to_vk("F1"), Some(0x70)); + assert_eq!(key_label_to_vk("F12"), Some(0x7B)); + } + + #[test] + fn unknown_label_returns_none() { + assert_eq!(key_label_to_vk("Eject Disc"), None); + assert_eq!(key_label_to_vk(""), None); + } + + #[test] + fn mouse_side_button_index_decodes_back_and_forward() { + assert_eq!(mouse_side_button_index("mouse-side-button:8"), Some(4)); + assert_eq!(mouse_side_button_index("mouse-side-button:16"), Some(5)); + assert_eq!(mouse_side_button_index("keyboard"), None); + assert_eq!(mouse_side_button_index("mouse-side-button:32"), None); + } +}