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
Generated
+1
View File
@@ -388,6 +388,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tsclientlib", "tsclientlib",
"windows",
"zbus", "zbus",
] ]
+13
View File
@@ -34,6 +34,19 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
jni = { version = "0.21", default-features = false } jni = { version = "0.21", default-features = false }
ndk-context = "0.1" 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] [dev-dependencies]
# `test-util` enables `start_paused` / virtual-clock tests used by # `test-util` enables `start_paused` / virtual-clock tests used by
# the missed-key-up watchdog unit tests. # the missed-key-up watchdog unit tests.
+54 -1
View File
@@ -142,6 +142,11 @@ impl AudioEngine {
mut voice_in_rx: mpsc::Receiver<InboundVoice>, mut voice_in_rx: mpsc::Receiver<InboundVoice>,
) -> Result<Self, AudioError> { ) -> Result<Self, AudioError> {
let host = cpal::default_host(); let host = cpal::default_host();
info!(
target: "chanora_audio",
host_id = ?host.id(),
"starting audio engine: cpal host selected"
);
let in_dev = host let in_dev = host
.default_input_device() .default_input_device()
.ok_or(AudioError::NoInputDevice)?; .ok_or(AudioError::NoInputDevice)?;
@@ -156,6 +161,45 @@ impl AudioEngine {
"starting audio engine" "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 // A.5 mobile-only preset acknowledgement. On Linux desktop
// the flag is ignored; on Android we log it so a future cpal // the flag is ignored; on Android we log it so a future cpal
// / Oboe wiring can be observed in the diagnostic export. // / Oboe wiring can be observed in the diagnostic export.
@@ -702,7 +746,16 @@ where
}, },
None, 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) Ok(stream)
} }
@@ -27,6 +27,8 @@ mod focused;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod windows; mod windows;
#[cfg(target_os = "windows")]
mod windows_keymap;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod macos; mod macos;
+722 -67
View File
@@ -8,26 +8,57 @@
//! //!
//! Both Raw Input and the low-level hook need a dedicated OS //! Both Raw Input and the low-level hook need a dedicated OS
//! thread because their callbacks fire on the thread that owns the //! thread because their callbacks fire on the thread that owns the
//! message-only window / hook handle. This file lays in the //! message-only window / hook handle. The thread runs a standard
//! scaffolding (`try_select` probe + descriptor reporting); the //! Win32 GetMessageW/TranslateMessage/DispatchMessageW pump until
//! live RawInput / SetWindowsHookEx wiring lands during platform //! `stop()` posts `WM_QUIT`.
//! verification on a Windows reference host. The backends start //!
//! out reporting their target capability honestly through //! Privacy (DEC-027 / SDD-090): translation of the bound key is
//! `descriptor()` and store the binding for the diagnostic export. //! 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::atomic::{AtomicBool, AtomicIsize, Ordering};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
use tracing::{info, warn}; use tracing::{info, warn};
use windows::core::{w, PCWSTR};
use super::{ use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM};
AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, 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}; 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 /// 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>> { pub fn try_select() -> Option<Box<dyn DesktopPttBackend>> {
if let Some(b) = WindowsRawInputBackend::try_new() { if let Some(b) = WindowsRawInputBackend::try_new() {
return Some(Box::new(b)); return Some(Box::new(b));
@@ -38,46 +69,151 @@ pub fn try_select() -> Option<Box<dyn DesktopPttBackend>> {
None 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 { pub struct WindowsRawInputBackend {
binding: PttBinding, binding: Arc<AtomicBinding>,
gate: Option<AudioTransmitGate>, 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<()>>, 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 { impl WindowsRawInputBackend {
fn try_new() -> Option<Self> { 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!( info!(
target: "chanora_audio", target: "chanora_audio",
"windows ptt: selecting Raw Input backend (RIDEV_INPUTSINK)" "windows ptt: selecting Raw Input backend (RIDEV_INPUTSINK)"
); );
Some(Self { Some(Self {
binding: PttBinding::none(), binding: Arc::new(AtomicBinding::new()),
gate: None, gate: None,
stop: Arc::new(AtomicBool::new(false)), worker_tid: Arc::new(AtomicIsize::new(0)),
worker: None, worker: None,
armed: Arc::new(AtomicBool::new(false)),
is_mouse: AtomicBool::new(false),
}) })
} }
} }
impl DesktopPttBackend for WindowsRawInputBackend { impl DesktopPttBackend for WindowsRawInputBackend {
fn descriptor(&self) -> PttBackendDescriptor { 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 { PttBackendDescriptor {
level: match self.binding.input_class { level,
super::PttInputClass::MouseSideButton => {
PttCapabilityLevel::L3GlobalWithMouseButtons
}
_ => PttCapabilityLevel::L2GlobalHoldToTalk,
},
backend_id: "raw-input", backend_id: "raw-input",
bound_input_class: match self.binding.class_str() { bound_input_class: match class {
"" => None, 0 => None,
"mouse-side-button" => Some("mouse-side-button"), 2 => Some("mouse-side-button"),
_ => Some("keyboard"), _ => Some("keyboard"),
}, },
} }
@@ -88,42 +224,128 @@ impl DesktopPttBackend for WindowsRawInputBackend {
gate: AudioTransmitGate, gate: AudioTransmitGate,
binding: PttBinding, binding: PttBinding,
) -> Result<(), PttBackendError> { ) -> 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.gate = Some(gate.clone());
self.binding = binding.clone();
let stop = self.stop.clone(); let binding_for_worker = self.binding.clone();
// Spawn the Raw Input message loop on its own OS thread. let worker_tid = self.worker_tid.clone();
// The thread lives until `stop()` flips the AtomicBool. let armed = self.armed.clone();
// Live RegisterRawInputDevices + WndProc wiring lands let gate_for_worker = gate.clone();
// during Windows platform verification; this scaffolding
// keeps the lifecycle correct so the rest of the system let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<bool>(1);
// (descriptor, watchdog, capability event) is exercised.
let handle = thread::Builder::new() let handle = thread::Builder::new()
.name("chanora-rawinput".into()) .name("chanora-rawinput".into())
.spawn(move || { .spawn(move || {
while !stop.load(Ordering::Relaxed) { // Stash our thread id so stop() can post WM_QUIT.
thread::sleep(std::time::Duration::from_millis(50)); 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}")))?; .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); self.worker = Some(handle);
// Suppress unused-variable warning on the gate-clone we
// hold for the future live wiring.
let _ = &gate;
Ok(()) Ok(())
} }
fn stop(&mut self) { 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() { if let Some(h) = self.worker.take() {
let _ = h.join(); let _ = h.join();
} }
if let Some(g) = self.gate.take() { if let Some(g) = self.gate.take() {
g.set(false); g.set(false);
} }
self.armed.store(false, Ordering::Release);
self.binding.clear();
} }
fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> { 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(()) 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. /// 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 { pub struct WindowsHookBackend {
binding: PttBinding, binding: Arc<AtomicBinding>,
gate: Option<AudioTransmitGate>, gate: Option<AudioTransmitGate>,
stop: Arc<AtomicBool>, worker_tid: Arc<AtomicIsize>,
worker: Option<thread::JoinHandle<()>>, worker: Option<thread::JoinHandle<()>>,
armed: Arc<AtomicBool>,
is_mouse: AtomicBool,
} }
impl WindowsHookBackend { impl WindowsHookBackend {
@@ -149,27 +614,32 @@ impl WindowsHookBackend {
"windows ptt: falling back to low-level keyboard hook (WH_KEYBOARD_LL)" "windows ptt: falling back to low-level keyboard hook (WH_KEYBOARD_LL)"
); );
Some(Self { Some(Self {
binding: PttBinding::none(), binding: Arc::new(AtomicBinding::new()),
gate: None, gate: None,
stop: Arc::new(AtomicBool::new(false)), worker_tid: Arc::new(AtomicIsize::new(0)),
worker: None, worker: None,
armed: Arc::new(AtomicBool::new(false)),
is_mouse: AtomicBool::new(false),
}) })
} }
} }
impl DesktopPttBackend for WindowsHookBackend { impl DesktopPttBackend for WindowsHookBackend {
fn descriptor(&self) -> PttBackendDescriptor { 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 { PttBackendDescriptor {
level: match self.binding.input_class { level,
super::PttInputClass::MouseSideButton => {
PttCapabilityLevel::L3GlobalWithMouseButtons
}
_ => PttCapabilityLevel::L2GlobalHoldToTalk,
},
backend_id: "low-level-hook", backend_id: "low-level-hook",
bound_input_class: match self.binding.class_str() { bound_input_class: match class {
"" => None, 0 => None,
"mouse-side-button" => Some("mouse-side-button"), 2 => Some("mouse-side-button"),
_ => Some("keyboard"), _ => Some("keyboard"),
}, },
} }
@@ -180,34 +650,93 @@ impl DesktopPttBackend for WindowsHookBackend {
gate: AudioTransmitGate, gate: AudioTransmitGate,
binding: PttBinding, binding: PttBinding,
) -> Result<(), PttBackendError> { ) -> 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.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() let handle = thread::Builder::new()
.name("chanora-llhook".into()) .name("chanora-llhook".into())
.spawn(move || { .spawn(move || {
while !stop.load(Ordering::Relaxed) { let tid = unsafe { windows::Win32::System::Threading::GetCurrentThreadId() };
thread::sleep(std::time::Duration::from_millis(50)); 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}")))?; .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); self.worker = Some(handle);
let _ = &gate;
Ok(()) Ok(())
} }
fn stop(&mut self) { 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() { if let Some(h) = self.worker.take() {
let _ = h.join(); let _ = h.join();
} }
if let Some(g) = self.gate.take() { if let Some(g) = self.gate.take() {
g.set(false); g.set(false);
} }
self.armed.store(false, Ordering::Release);
self.binding.clear();
} }
fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> { 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(()) Ok(())
} }
} }
@@ -217,3 +746,129 @@ impl Drop for WindowsHookBackend {
self.stop(); 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)
}
@@ -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<u16> {
// 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 <n>" / "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:<bitmask>"`)
/// 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<u8> {
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);
}
}