Files
chanora/crates/chanora_audio/src/ptt_backends/mod.rs
T
EdisonJwa 77c2a1def4 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.
2026-05-15 22:01:28 +08:00

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())
}