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:
Generated
+1
@@ -388,6 +388,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tsclientlib",
|
||||
"windows",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -142,6 +142,11 @@ impl AudioEngine {
|
||||
mut voice_in_rx: mpsc::Receiver<InboundVoice>,
|
||||
) -> Result<Self, AudioError> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user