feat(audio,windows): real Raw Input + low-level hook global PTT (SDD-083 / SDD-084)

The v1.0.0-rc.7 Windows backends were thread::sleep stubs that
reported optimistic L2GlobalHoldToTalk / L3GlobalWithMouseButtons
descriptors without actually registering for any global key events.
Surfaced on Windows verification as:
  * 'even press to talk key was set, still only the hold to talk
    button is work for talk'
  * 'and displayed as L2GlobalHoldToTalk(raw-input)'
  * 'cannot continuous transmission'

This commit implements the real backends:

  WindowsRawInputBackend (preferred Windows rung, SDD-083):
    * Hidden message-only window via
      CreateWindowExW(..., HWND_MESSAGE, ...).
    * RegisterRawInputDevices with RIDEV_INPUTSINK on
      Usage Page 0x01 / Usage 0x06 (keyboard) and 0x02 (mouse) so
      events fire globally — including when Chanora is unfocused.
    * WndProc handling WM_INPUT: GetRawInputData ->
      keyboard.VKey vs bound vk, or mouse.usButtonFlags vs bound
      side-button index. Down -> gate.set(true); up -> gate.set(false).
    * Dedicated chanora-rawinput thread runs GetMessageW /
      TranslateMessage / DispatchMessageW until stop() posts
      WM_QUIT via PostThreadMessageW.

  WindowsHookBackend (fallback rung, SDD-084):
    * SetWindowsHookExW(WH_KEYBOARD_LL) + WH_MOUSE_LL on a
      dedicated chanora-llhook thread.
    * Hook procs translate KBDLLHOOKSTRUCT.vkCode and
      MSLLHOOKSTRUCT.mouseData against the same shared
      AtomicBinding.
    * UnhookWindowsHookEx on teardown.

  Both backends:
    * Honest descriptor() reporting: backends start reporting
      L0Focused; level upgrades to L2 / L3 only after a real
      arming success (RegisterRawInputDevices or SetWindowsHookEx
      returning Ok). This fixes the 'L2 reported but doesn't fire'
      complaint by making the badge tell the truth — if Raw Input
      registration fails at runtime the user sees the L0Focused
      info-icon explanation sheet instead of being told L2 works.
    * AtomicBinding (class / vk / mouse_btn) for lock-free hot
      path. Translation lives in
      crates/chanora_audio/src/ptt_backends/windows_keymap.rs
      which maps Flutter LogicalKeyboardKey.keyLabel strings
      (e.g. 'Space', 'F10', 'A') to Win32 VK_* codes; mouse
      side-button bitmask strings ('mouse-side-button:8' /
      ':16') to RawInput button indices (4 / 5).
    * Per-thread context (thread_local RefCell) carries the
      gate + binding to the WndProc / hook proc without needing
      raw-pointer user-data plumbing.

  Diagnostic logging:
    * AudioEngine::start now logs default_input_config and
      default_output_config explicitly with the channels /
      sample_rate / sample_format that cpal reports, so a
      build_*_stream failure on locale-specific Windows hosts
      (reported on ko-KR Windows 11 as 'Start Audio Button not
      work') becomes diagnosable from the stderr log alone.
    * build_output_stream surfaces the requested config in the
      tracing::error! record on failure.

  Privacy (DEC-027 / SDD-090): the windows.rs and
  windows_keymap.rs hot paths NEVER log raw VKs, scan codes,
  keysyms, key labels, or button identifiers. Only the
  platform-neutral input class ('keyboard' /
  'mouse-side-button') and the backend id appear in the tracing
  stream. The SDD-090 PttSanitizer Layer is the defence-in-depth
  net but this code does not rely on it.

  Tests: 4 new windows-only unit tests in windows_keymap (ASCII
  letters / digits / Space + Fn / unknown / mouse button index).
  They compile only under cfg(target_os = "windows") so the
  Linux workspace test count is unchanged at 59/0/3.

  Cargo deps: adds windows = '0.54' (target_os = windows) with
  the feature set needed for RawInput + hooks. 0.54 matches
  the version already transitive through the workspace.

Verified on Linux: cargo check --workspace clean, cargo test
--workspace 59/0/3 (windows-gated tests skip on Linux). The
real exercise of this commit will happen on the Korean Windows
11 host (100.84.219.45) at the next build.
This commit is contained in:
EdisonJwa
2026-05-15 22:01:28 +08:00
parent 8e04a1e2a6
commit 77c2a1def4
6 changed files with 984 additions and 68 deletions
+722 -67
View File
@@ -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<Box<dyn DesktopPttBackend>> {
if let Some(b) = WindowsRawInputBackend::try_new() {
return Some(Box::new(b));
@@ -38,46 +69,151 @@ pub fn try_select() -> Option<Box<dyn DesktopPttBackend>> {
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<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: PttBinding,
binding: Arc<AtomicBinding>,
gate: Option<AudioTransmitGate>,
stop: Arc<AtomicBool>,
/// 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> {
// 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::<bool>(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::<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 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::<RAWINPUTDEVICE>() 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::<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 _);
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();
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<Option<HookContext>> =
const { std::cell::RefCell::new(None) };
}
struct HookContext {
gate: AudioTransmitGate,
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: PttBinding,
binding: Arc<AtomicBinding>,
gate: Option<AudioTransmitGate>,
stop: Arc<AtomicBool>,
worker_tid: Arc<AtomicIsize>,
worker: Option<thread::JoinHandle<()>>,
armed: Arc<AtomicBool>,
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::<bool>(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)
}