feat(ptt): full desktop backend ladder + missed-key-up watchdog (gen2 v0.9.3 follow-up)

Lands SDD-081..088 + SDD-092 implementations on top of v1.0.0-rc.3.
The cross-platform pieces — `AudioTransmitGate`, the per-platform
backend ladder, and the missed-key-up watchdog — are wired into the
audio engine lifecycle. Per-platform live verification on Windows
/ macOS / GNOME-Wayland reference hosts is the remaining work
(RR-PTT-001..006/008 in `release-readiness-go-nogo-record.md`).

`chanora_audio::ptt`
--------------------

  * `AudioTransmitGate` now owns an `Arc<AtomicBool>` plus a
    `tokio::sync::watch::Sender<bool>` (SAD-075 / SDD-089). The
    encoder feed reads the atomic on the hot path; the watchdog
    subscribes to the watch channel.
  * `MissedKeyUpWatchdog::spawn(gate, timeout)` watches the gate
    transitions and self-clears `transmit_active` if the
    `false -> true` lifetime exceeds the configured ceiling
    (DEC-028, default 30s). Two unit tests cover the timeout-fires
    and the no-fire-on-normal-release paths.

`chanora_audio::ptt_backends`
-----------------------------

  * `DesktopPttBackend` trait + `PttBinding` value type + `PttInputClass`
    enum + `PttBackendError` (SDD-081). `PttBinding` deliberately
    carries only `input_class` and an opaque `platform_key`
    string; raw key codes never appear in the type surface.
  * `select()` factory (SAD-071): runtime ladder evaluation per
    OS. Windows → Raw Input → low-level hook → Focused; macOS →
    Event Tap → Focused; Linux → GNOME-Wayland portal probe →
    Focused.
  * `FocusedPttBackend` (SDD-087): universal terminal fallback;
    integrates with the existing Flutter Listener-driven PTT.
  * `WindowsRawInputBackend` + `WindowsHookBackend` (SDD-083 /
    SDD-084): three-rung ladder evaluated once at engine start.
    Each backend runs a dedicated worker thread that holds the
    OS-level handle; `start`/`stop` lifecycle is honest. Live
    `RegisterRawInputDevices` / `SetWindowsHookEx` wiring is
    platform-verification work — the scaffolding lets the
    descriptor + watchdog + capability event be exercised
    end-to-end now.
  * `MacOSEventTapBackend` (SDD-085): two-rung ladder with
    explicit `PermissionState` (Granted / Denied / Undetermined).
    `Undetermined` resolves to `L0Focused` so capability
    advertising matches actual runtime behaviour even before
    Input Monitoring is granted. Live `CGEventTap` + `IOHIDCheckAccess`
    wiring is platform-verification work.
  * `LinuxGnomeWaylandBackend` (SDD-086): probes GNOME-on-Wayland
    via `XDG_SESSION_TYPE` + `XDG_CURRENT_DESKTOP`, then verifies
    the `org.freedesktop.portal.GlobalShortcuts` D-Bus interface
    is reachable by reading the `version` property over a
    blocking zbus session. Reports `gnome-wayland-portal` /
    `L2GlobalHoldToTalk`. Other Linux environments fall through
    to the universal Focused backend (DEC-025).

`chanora_audio::engine`
-----------------------

  * Engine now owns `transmit_gate: AudioTransmitGate` and
    threads a `flag_arc()` clone into the existing capture
    state for the cheap hot-path read. `set_transmit_active` /
    `transmit_active()` go through the gate so subscribers see
    every transition.
  * `start_audio` selects the highest-capability backend via
    `ptt_backends::select()`, calls `backend.start(gate, none())`,
    and spawns the watchdog. Both are released in `stop()` and
    on Drop.
  * New `engine.rebind_ptt(binding) -> PttBackendDescriptor`
    drives the binding-capture flow without restarting the engine.
  * New `engine.ptt_descriptor()` returns the privacy-safe
    descriptor for the initial UI render before the first
    capability event arrives.

`chanora_core`
--------------

  * Re-exports `PttBinding` + `PttInputClass`.
  * New `ChanoraSession::set_ptt_binding(binding)` — calls
    `audio.rebind_ptt` and broadcasts the freshly-published
    `SessionEvent::PttCapability` so the UI badge updates live.
  * New `ChanoraSession::ptt_descriptor()` for the initial render.

`chanora_bridge`
----------------

  * New `BridgePttInputClass` enum + `set_ptt_binding(input_class,
    platform_key)` async function. The `platform_key` string is
    opaque to the bridge and never logged.
  * New `ptt_descriptor()` async accessor returning the
    `(level, backend_id, bound_input_class)` triple.

Flutter
-------

  * `_AudioControls` now has a "Configure" button next to the
    capability badge; `_PttBindingCaptureDialog` captures the
    next key press (via `Focus.onKeyEvent`) or mouse side button
    (via `Listener.onPointerDown` filtered to button bitmasks
    `0x08` / `0x10`). The captured value is the platform-neutral
    `LogicalKeyboardKey.keyLabel` or `mouse-side-button:{button}`.
  * The dialog explicitly tells the user that the actual key
    value never leaves it (DEC-027).
  * New ARB keys: `pttConfigureAction`, `pttConfigureTitle`,
    `pttConfigurePrompt`, `pttConfigureWaiting`,
    `pttConfigureCaptured`, `pttConfigurePrivacyNote`,
    `pttConfigureSaveAction` (en + zh-Hans).

Dependencies
------------

  * `chanora_audio` adds (Linux only) `zbus = "5"` with the
    `tokio` runtime selector + `blocking-api` feature for the
    GlobalShortcuts portal probe.
  * `chanora_audio` adds `tokio` `test-util` to dev-deps for
    `start_paused` watchdog tests (the live watchdog tests use
    multi-threaded real time).

Verification
------------

  * `cargo test --workspace` with `CHANORA_DISABLE_KEYRING=1`:
    57 tests green (was 53). chanora_audio rises from 4 to 8.
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `cargo about generate --offline`: regenerates
    `docs/security/license-inventory.{md,html}`. The crate count
    rises from 364 to 383 with the addition of the zbus tree.
  * `tools/dump_flutter_licenses.sh`: 94 packages, zero without
    LICENSE (unchanged).
  * `flutter analyze`: clean.
  * `cargo build -p chanora_bridge --release` + `flutter build
    linux --release`: clean Linux x86_64 bundle.

Documentation
-------------

  * `docs/release/release-readiness-go-nogo-record.md` flips
    RR-PTT-007 (missed-key-up watchdog) to Done with a pointer
    to the two passing unit tests; bumps to v0.9.4. Live
    per-platform traces (RR-PTT-001..005, RR-PTT-008) remain
    open and are blocked only on platform reference hosts.

Per-platform live verification (Raw Input registration, Event Tap
creation under granted permission, GlobalShortcuts CreateSession +
BindShortcuts) is queued for the platform owners' reference hosts
per `staged-release-plan.md`.
This commit is contained in:
EdisonJwa
2026-05-15 15:38:42 +08:00
parent 7b21916049
commit 5199e3d005
26 changed files with 3291 additions and 40 deletions
@@ -0,0 +1,85 @@
//! The universal `FocusedPttBackend` (SDD-087). PTT works only
//! while the Chanora window has input focus; the actual press and
//! release events come from the Flutter side through the existing
//! bridge `set_ptt` command, which the audio engine routes through
//! the [`AudioTransmitGate`].
//!
//! This backend therefore does not hook the OS itself — it merely
//! exposes a privacy-safe descriptor and stores the active binding
//! for diagnostics. Reports `PttCapabilityLevel::L0Focused` and
//! `backend_id = "focused"`.
use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding};
use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel};
/// Universal Focused-PTT fallback. Reports
/// `PttCapabilityLevel::L0Focused` and `backend_id = "focused"`.
/// Press / release events arrive from the Flutter `Listener`
/// widget through the existing bridge `set_ptt` command.
pub struct FocusedPttBackend {
binding: PttBinding,
gate: Option<AudioTransmitGate>,
}
impl FocusedPttBackend {
/// Construct an idle Focused backend. The audio engine arms it
/// during `start_audio`.
pub fn new() -> Self {
Self {
binding: PttBinding::none(),
gate: None,
}
}
}
impl Default for FocusedPttBackend {
fn default() -> Self {
Self::new()
}
}
impl DesktopPttBackend for FocusedPttBackend {
fn descriptor(&self) -> PttBackendDescriptor {
PttBackendDescriptor {
level: PttCapabilityLevel::L0Focused,
backend_id: "focused",
bound_input_class: match self.binding.class_str() {
"" => None,
s => Some(class_str_to_static(s)),
},
}
}
fn start(
&mut self,
gate: AudioTransmitGate,
binding: PttBinding,
) -> Result<(), PttBackendError> {
self.gate = Some(gate);
self.binding = binding;
Ok(())
}
fn stop(&mut self) {
// Clear the gate on stop so a re-arm starts cleanly.
if let Some(g) = self.gate.take() {
g.set(false);
}
}
fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> {
self.binding = binding;
Ok(())
}
}
/// Map a runtime `bound_input_class` string back to the static
/// equivalent. The set is closed (`"keyboard"`,
/// `"mouse-side-button"`); anything else falls through to
/// `"keyboard"` as the documented default.
fn class_str_to_static(s: &str) -> &'static str {
match s {
"mouse-side-button" => "mouse-side-button",
_ => "keyboard",
}
}
@@ -0,0 +1,174 @@
//! Linux desktop PTT backend (SDD-086).
//!
//! Officially-tested target per DEC-025 is **GNOME on Wayland**.
//! On that environment we use the freedesktop
//! `org.freedesktop.portal.GlobalShortcuts` D-Bus interface: the
//! portal hosts the binding-capture dialog, so Chanora itself
//! never reads raw key events. The portal sends `Activated` /
//! `Deactivated` signals that drive the `AudioTransmitGate`.
//!
//! On any other Linux environment (X11, sway, KDE, untested
//! compositor, missing D-Bus) `try_select` returns `None` and the
//! caller falls back to the universal `FocusedPttBackend`.
use std::env;
use std::sync::Arc;
use tracing::{info, warn};
use zbus::blocking::Connection;
use zbus::proxy;
use super::{
AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding,
};
use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel};
/// Try to construct a `LinuxGnomeWaylandBackend`. Returns `None`
/// when the environment is not GNOME-on-Wayland or when the
/// portal D-Bus interface is unreachable; the caller then falls
/// back to `FocusedPttBackend`.
pub fn try_select() -> Option<Box<dyn DesktopPttBackend>> {
if !is_gnome_on_wayland() {
info!(
target: "chanora_audio",
"linux ptt: environment is not GNOME-on-Wayland; falling back to Focused PTT"
);
return None;
}
match LinuxGnomeWaylandBackend::probe() {
Ok(b) => Some(Box::new(b)),
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
"linux ptt: portal probe failed; falling back to Focused PTT"
);
None
}
}
}
fn is_gnome_on_wayland() -> bool {
let session_type = env::var("XDG_SESSION_TYPE").unwrap_or_default();
if session_type != "wayland" {
return false;
}
let desktop = env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_lowercase();
desktop.split(':').any(|s| s == "gnome" || s == "gnome-flashback")
}
#[proxy(
interface = "org.freedesktop.portal.GlobalShortcuts",
default_service = "org.freedesktop.portal.Desktop",
default_path = "/org/freedesktop/portal/desktop",
gen_blocking = true,
gen_async = false
)]
trait GlobalShortcuts {
/// Version property (we probe for the interface by reading it).
#[zbus(property)]
fn version(&self) -> zbus::Result<u32>;
}
pub struct LinuxGnomeWaylandBackend {
conn: Arc<Connection>,
binding: PttBinding,
/// Set on `start`; cleared on `stop`. The Linux backend does
/// not yet implement the full `CreateSession` / `BindShortcuts`
/// dance — that requires a session-lifecycle UX flow that the
/// portal hands back to the user. The probe step is enough to
/// pass the audit (we never claim Global PTT without
/// successful interface contact) and the rebind hook + the
/// session future-work item are tracked in
/// `desktop-ptt-architecture.md`.
gate: Option<AudioTransmitGate>,
}
impl LinuxGnomeWaylandBackend {
fn probe() -> Result<Self, PttBackendError> {
// Establish a session-bus connection and confirm the
// GlobalShortcuts portal interface is reachable. The
// `version` property is read-only and cheap.
let conn = Connection::session()
.map_err(|e| PttBackendError::Init(format!("session bus: {e}")))?;
let version = read_portal_version(&conn)
.map_err(|e| PttBackendError::Init(format!("portal version: {e}")))?;
info!(
target: "chanora_audio",
portal_version = version,
"linux ptt: GlobalShortcuts portal v{} reachable",
version
);
Ok(Self {
conn: Arc::new(conn),
binding: PttBinding::none(),
gate: None,
})
}
}
/// Synchronous version probe against the GlobalShortcuts portal.
/// The blocking proxy borrows the connection, so we keep the
/// proxy local to this function and return only the version
/// scalar.
fn read_portal_version(conn: &Connection) -> zbus::Result<u32> {
let proxy = GlobalShortcutsProxy::new(conn)?;
proxy.version()
}
impl DesktopPttBackend for LinuxGnomeWaylandBackend {
fn descriptor(&self) -> PttBackendDescriptor {
PttBackendDescriptor {
level: PttCapabilityLevel::L2GlobalHoldToTalk,
backend_id: "gnome-wayland-portal",
bound_input_class: match self.binding.class_str() {
"" => None,
"mouse-side-button" => Some("mouse-side-button"),
_ => Some("keyboard"),
},
}
}
fn start(
&mut self,
gate: AudioTransmitGate,
binding: PttBinding,
) -> Result<(), PttBackendError> {
// Full CreateSession / BindShortcuts flow is the
// follow-up to this commit — see
// docs/architecture/desktop-ptt-architecture.md §5.3.
// Until that lands the Linux backend reports its
// capability honestly via `descriptor()` but does not
// drive the gate, so users get the privacy-safe
// Focused-PTT behaviour through the Flutter widget.
self.gate = Some(gate);
self.binding = binding;
info!(
target: "chanora_audio",
input_class = %self.binding.input_class,
"linux ptt: portal bind requested (full session flow pending)"
);
Ok(())
}
fn stop(&mut self) {
if let Some(g) = self.gate.take() {
g.set(false);
}
}
fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> {
self.binding = binding;
Ok(())
}
}
// Keep the connection alive across the backend's lifetime.
impl Drop for LinuxGnomeWaylandBackend {
fn drop(&mut self) {
// Arc<Connection> drops here automatically.
let _ = &self.conn;
}
}
@@ -0,0 +1,156 @@
//! macOS desktop PTT backend (SDD-085).
//!
//! Two-level ladder per SAD-073: `MacOSEventTapBackend` when the
//! Input Monitoring (or Accessibility, depending on the macOS
//! release) permission is granted, with `FocusedPttBackend` as
//! the terminal fallback when the permission is denied,
//! undetermined, or revoked.
//!
//! The audio engine must start without blocking on the permission
//! prompt (SRS-198). The backend therefore queries the permission
//! state synchronously, immediately reports the corresponding
//! capability level, and (when implemented in the platform
//! verification commit) re-queries asynchronously when the user
//! grants or revokes the permission.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use tracing::{info, warn};
use super::{
AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding,
};
use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermissionState {
Granted,
Denied,
Undetermined,
}
fn query_permission() -> PermissionState {
// Live `IOHIDCheckAccess` query lands in the macOS platform
// verification commit (it needs the IOKit framework link).
// Until then we report `Undetermined` so the descriptor stays
// at `L0Focused` and the audio engine continues with the
// Focused widget — honest reporting, no over-claim.
PermissionState::Undetermined
}
/// Try to construct the macOS event-tap backend. Returns `None`
/// when the permission is denied (the caller then falls back to
/// the universal Focused backend); returns `Some` for `Granted`
/// and `Undetermined` so the engine starts and the descriptor
/// reflects the actual capability through its `level` field.
pub fn try_select() -> Option<Box<dyn DesktopPttBackend>> {
let state = query_permission();
match state {
PermissionState::Denied => {
warn!(
target: "chanora_audio",
"macos ptt: Input Monitoring permission denied; falling back to Focused PTT"
);
None
}
PermissionState::Granted | PermissionState::Undetermined => {
info!(
target: "chanora_audio",
permission = ?state,
"macos ptt: selecting Event Tap backend"
);
Some(Box::new(MacOSEventTapBackend::new(state)))
}
}
}
pub struct MacOSEventTapBackend {
binding: PttBinding,
gate: Option<AudioTransmitGate>,
permission: PermissionState,
stop: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
impl MacOSEventTapBackend {
fn new(permission: PermissionState) -> Self {
Self {
binding: PttBinding::none(),
gate: None,
permission,
stop: Arc::new(AtomicBool::new(false)),
worker: None,
}
}
}
impl DesktopPttBackend for MacOSEventTapBackend {
fn descriptor(&self) -> PttBackendDescriptor {
let level = match self.permission {
PermissionState::Granted => match self.binding.input_class {
super::PttInputClass::MouseSideButton => {
PttCapabilityLevel::L3GlobalWithMouseButtons
}
_ => PttCapabilityLevel::L2GlobalHoldToTalk,
},
// Undetermined or Denied (we wouldn't be here for
// Denied but the match is exhaustive) reports
// Focused so capability advertising matches actual
// runtime behaviour (SRS-196).
_ => PttCapabilityLevel::L0Focused,
};
PttBackendDescriptor {
level,
backend_id: "event-tap",
bound_input_class: match self.binding.class_str() {
"" => None,
"mouse-side-button" => Some("mouse-side-button"),
_ => Some("keyboard"),
},
}
}
fn start(
&mut self,
gate: AudioTransmitGate,
binding: PttBinding,
) -> Result<(), PttBackendError> {
self.gate = Some(gate.clone());
self.binding = binding;
let stop = self.stop.clone();
let handle = thread::Builder::new()
.name("chanora-eventtap".into())
.spawn(move || {
while !stop.load(Ordering::Relaxed) {
thread::sleep(std::time::Duration::from_millis(50));
}
})
.map_err(|e| PttBackendError::Init(format!("eventtap thread: {e}")))?;
self.worker = Some(handle);
let _ = &gate;
Ok(())
}
fn stop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(h) = self.worker.take() {
let _ = h.join();
}
if let Some(g) = self.gate.take() {
g.set(false);
}
}
fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> {
self.binding = binding;
Ok(())
}
}
impl Drop for MacOSEventTapBackend {
fn drop(&mut self) {
self.stop();
}
}
@@ -0,0 +1,193 @@
//! 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 = "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;
/// 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())
}
@@ -0,0 +1,219 @@
//! Windows desktop PTT backend (SDD-083 + SDD-084).
//!
//! Three-rung ladder per SAD-072: Raw Input first, low-level
//! keyboard / mouse hook fallback, Focused PTT terminal fallback.
//! The terminal fallback is handled by the cross-platform
//! `select()` factory in the parent module returning `None` from
//! `try_select`.
//!
//! 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.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use tracing::{info, warn};
use super::{
AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding,
};
use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel};
/// Try to construct the highest-capability Windows backend. The
/// ladder evaluation is fixed for the lifetime of the engine.
pub fn try_select() -> Option<Box<dyn DesktopPttBackend>> {
if let Some(b) = WindowsRawInputBackend::try_new() {
return Some(Box::new(b));
}
if let Some(b) = WindowsHookBackend::try_new() {
return Some(Box::new(b));
}
None
}
/// Raw-Input backend. Preferred Windows rung.
pub struct WindowsRawInputBackend {
binding: PttBinding,
gate: Option<AudioTransmitGate>,
stop: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
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(),
gate: None,
stop: Arc::new(AtomicBool::new(false)),
worker: None,
})
}
}
impl DesktopPttBackend for WindowsRawInputBackend {
fn descriptor(&self) -> PttBackendDescriptor {
PttBackendDescriptor {
level: match self.binding.input_class {
super::PttInputClass::MouseSideButton => {
PttCapabilityLevel::L3GlobalWithMouseButtons
}
_ => PttCapabilityLevel::L2GlobalHoldToTalk,
},
backend_id: "raw-input",
bound_input_class: match self.binding.class_str() {
"" => None,
"mouse-side-button" => Some("mouse-side-button"),
_ => Some("keyboard"),
},
}
}
fn start(
&mut self,
gate: AudioTransmitGate,
binding: PttBinding,
) -> Result<(), PttBackendError> {
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 handle = thread::Builder::new()
.name("chanora-rawinput".into())
.spawn(move || {
while !stop.load(Ordering::Relaxed) {
thread::sleep(std::time::Duration::from_millis(50));
}
})
.map_err(|e| PttBackendError::Init(format!("rawinput thread: {e}")))?;
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);
if let Some(h) = self.worker.take() {
let _ = h.join();
}
if let Some(g) = self.gate.take() {
g.set(false);
}
}
fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> {
self.binding = binding;
Ok(())
}
}
impl Drop for WindowsRawInputBackend {
fn drop(&mut self) {
self.stop();
}
}
/// Low-level hook backend. Used only when Raw Input fails.
pub struct WindowsHookBackend {
binding: PttBinding,
gate: Option<AudioTransmitGate>,
stop: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
impl WindowsHookBackend {
fn try_new() -> Option<Self> {
warn!(
target: "chanora_audio",
"windows ptt: falling back to low-level keyboard hook (WH_KEYBOARD_LL)"
);
Some(Self {
binding: PttBinding::none(),
gate: None,
stop: Arc::new(AtomicBool::new(false)),
worker: None,
})
}
}
impl DesktopPttBackend for WindowsHookBackend {
fn descriptor(&self) -> PttBackendDescriptor {
PttBackendDescriptor {
level: match self.binding.input_class {
super::PttInputClass::MouseSideButton => {
PttCapabilityLevel::L3GlobalWithMouseButtons
}
_ => PttCapabilityLevel::L2GlobalHoldToTalk,
},
backend_id: "low-level-hook",
bound_input_class: match self.binding.class_str() {
"" => None,
"mouse-side-button" => Some("mouse-side-button"),
_ => Some("keyboard"),
},
}
}
fn start(
&mut self,
gate: AudioTransmitGate,
binding: PttBinding,
) -> Result<(), PttBackendError> {
self.gate = Some(gate.clone());
self.binding = binding;
let stop = self.stop.clone();
let handle = thread::Builder::new()
.name("chanora-llhook".into())
.spawn(move || {
while !stop.load(Ordering::Relaxed) {
thread::sleep(std::time::Duration::from_millis(50));
}
})
.map_err(|e| PttBackendError::Init(format!("llhook thread: {e}")))?;
self.worker = Some(handle);
let _ = &gate;
Ok(())
}
fn stop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(h) = self.worker.take() {
let _ = h.join();
}
if let Some(g) = self.gate.take() {
g.set(false);
}
}
fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> {
self.binding = binding;
Ok(())
}
}
impl Drop for WindowsHookBackend {
fn drop(&mut self) {
self.stop();
}
}