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.
217 lines
7.5 KiB
Rust
217 lines
7.5 KiB
Rust
//! Platform-specific desktop Push-to-Talk backends (SDD-081 trait,
|
|
//! SDD-083..087 implementations).
|
|
//!
|
|
//! The cross-platform trait surface lives in this module's root;
|
|
//! per-OS implementations live in the platform sub-modules and are
|
|
//! conditionally compiled. `select()` is the runtime factory the
|
|
//! audio engine calls during `start_audio`.
|
|
//!
|
|
//! Every backend has the same shape:
|
|
//! * `start(gate, binding)` arms the backend.
|
|
//! * `stop()` releases OS-level resources.
|
|
//! * `rebind(binding)` updates the active binding without
|
|
//! restarting the backend (used by the UI binding-capture
|
|
//! flow).
|
|
//! * `descriptor()` returns the privacy-safe descriptor for
|
|
//! diagnostics + the UI capability badge.
|
|
//!
|
|
//! Backends never log raw key codes or scan codes — only the
|
|
//! stable `bound_input_class` string ("keyboard",
|
|
//! "mouse-side-button") plus the backend identifier.
|
|
|
|
use core::fmt;
|
|
|
|
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
|
|
|
|
mod focused;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
mod windows;
|
|
#[cfg(target_os = "windows")]
|
|
mod windows_keymap;
|
|
|
|
#[cfg(target_os = "macos")]
|
|
mod macos;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
mod linux;
|
|
|
|
pub use focused::FocusedPttBackend;
|
|
|
|
/// User-bound PTT input. The struct deliberately carries only
|
|
/// privacy-safe coarse-grained values so the diagnostics rule
|
|
/// (DEC-027) holds at the type level. The OS-side key identity
|
|
/// stays inside the platform backend implementation and is never
|
|
/// exposed across this surface.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PttBinding {
|
|
/// Coarse-grained class. Stable values: `"keyboard"`,
|
|
/// `"mouse-side-button"`. Future levels (DEC-026 mouse buttons
|
|
/// on Linux portal) may add `"mouse-other"`.
|
|
pub input_class: PttInputClass,
|
|
/// Opaque platform-defined key value. The value is a string so
|
|
/// diverse platform representations (Windows scan code, macOS
|
|
/// key code, Linux portal trigger description) all fit. The
|
|
/// diagnostics sanitizer's banned-field rule (SAD-077 /
|
|
/// SDD-090) prevents this field from being logged because it
|
|
/// never appears in a tracing record — the audio + bridge
|
|
/// layers consult only `input_class` and the backend `descriptor()`.
|
|
pub platform_key: String,
|
|
}
|
|
|
|
impl PttBinding {
|
|
/// A "no binding" sentinel. Backends never produce a
|
|
/// transmit-active event from this value.
|
|
pub fn none() -> Self {
|
|
Self {
|
|
input_class: PttInputClass::None,
|
|
platform_key: String::new(),
|
|
}
|
|
}
|
|
|
|
/// Public coarse string used by the diagnostic export and the
|
|
/// UI badge.
|
|
pub fn class_str(&self) -> &'static str {
|
|
self.input_class.as_str()
|
|
}
|
|
}
|
|
|
|
/// Coarse input class. Stable strings shared by the diagnostics
|
|
/// export, the UI capability badge, and the release verification
|
|
/// record.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum PttInputClass {
|
|
/// No binding is active.
|
|
None,
|
|
/// A keyboard key.
|
|
Keyboard,
|
|
/// A mouse side button (Mouse4 / Mouse5).
|
|
MouseSideButton,
|
|
}
|
|
|
|
impl PttInputClass {
|
|
/// Stable identifier for diagnostics. The value is never the
|
|
/// raw key code or scan code — see DEC-027.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::None => "",
|
|
Self::Keyboard => "keyboard",
|
|
Self::MouseSideButton => "mouse-side-button",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for PttInputClass {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
/// Errors raised by a desktop PTT backend.
|
|
#[derive(Debug)]
|
|
pub enum PttBackendError {
|
|
/// The OS rejected the backend initialisation (e.g. Raw Input
|
|
/// registration failed, event tap creation failed).
|
|
Init(String),
|
|
/// The user-granted permission required for global capture is
|
|
/// not granted (typically macOS Input Monitoring / Accessibility).
|
|
PermissionDenied,
|
|
/// The display server or compositor does not expose the
|
|
/// expected interface (typically a non-tested Linux compositor).
|
|
UnsupportedEnvironment,
|
|
/// Caller submitted a binding whose `platform_key` cannot be
|
|
/// parsed in the active OS.
|
|
InvalidBinding(String),
|
|
}
|
|
|
|
impl fmt::Display for PttBackendError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Init(s) => write!(f, "init failed: {s}"),
|
|
Self::PermissionDenied => f.write_str("permission denied"),
|
|
Self::UnsupportedEnvironment => f.write_str("unsupported environment"),
|
|
Self::InvalidBinding(s) => write!(f, "invalid binding: {s}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for PttBackendError {}
|
|
|
|
/// Cross-platform desktop PTT backend (SDD-081).
|
|
///
|
|
/// All implementations call exactly the audio transmit gate's
|
|
/// `set(bool)` method to drive `transmit_active`; they never log
|
|
/// raw key data.
|
|
pub trait DesktopPttBackend: Send {
|
|
/// Privacy-safe descriptor of this backend instance.
|
|
fn descriptor(&self) -> PttBackendDescriptor;
|
|
|
|
/// Subscribe to descriptor transitions. Implementations that
|
|
/// cannot transition asynchronously (`FocusedPttBackend`,
|
|
/// `WindowsRawInputBackend`, `WindowsHookBackend`,
|
|
/// `MacOSEventTapBackend` today) return a receiver that
|
|
/// observes only the initial value and never fires again. The
|
|
/// Linux portal backend uses this channel to publish the
|
|
/// post-`BindShortcuts` capability transition (Bound /
|
|
/// Cancelled).
|
|
fn descriptor_watch(&self) -> tokio::sync::watch::Receiver<PttBackendDescriptor> {
|
|
let (_tx, rx) = tokio::sync::watch::channel(self.descriptor());
|
|
// Hold `_tx` alive by leaking it — receivers detect the
|
|
// sender drop via `changed()` returning `Err`, which the
|
|
// core supervisor treats as "no further updates". By
|
|
// leaking we instead keep the receiver "live" indefinitely
|
|
// (no spurious closed-channel events), and the channel is
|
|
// dropped together with the audio engine because the
|
|
// backend Drop releases the box.
|
|
std::mem::forget(_tx);
|
|
rx
|
|
}
|
|
|
|
/// Arm the backend. After this call the backend listens for
|
|
/// the bound input and toggles the gate accordingly.
|
|
fn start(
|
|
&mut self,
|
|
gate: AudioTransmitGate,
|
|
binding: PttBinding,
|
|
) -> Result<(), PttBackendError>;
|
|
|
|
/// Release OS-level resources. Idempotent. The backend
|
|
/// instance may be dropped immediately after.
|
|
fn stop(&mut self);
|
|
|
|
/// Replace the active binding without restarting the backend.
|
|
/// May fail with `PttBackendError::InvalidBinding` if the new
|
|
/// binding cannot be honoured.
|
|
fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError>;
|
|
}
|
|
|
|
/// Runtime factory (SAD-071 / `platform_input::select`). Returns
|
|
/// the highest-capability backend the current OS, permission set,
|
|
/// and display server permit, falling back through the ladder
|
|
/// described in `desktop-ptt-architecture.md` to the universal
|
|
/// `FocusedPttBackend`.
|
|
///
|
|
/// The factory never fails — `FocusedPttBackend` is always
|
|
/// constructible.
|
|
pub fn select() -> Box<dyn DesktopPttBackend> {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
if let Some(b) = windows::try_select() {
|
|
return b;
|
|
}
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
if let Some(b) = macos::try_select() {
|
|
return b;
|
|
}
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
if let Some(b) = linux::try_select() {
|
|
return b;
|
|
}
|
|
}
|
|
Box::new(FocusedPttBackend::new())
|
|
}
|