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:
@@ -1,20 +1,25 @@
|
||||
//! Desktop Push-to-Talk capability model (SRS-195 / SRS-196 / SAD-071 /
|
||||
//! SDD-081 / SDD-082).
|
||||
//!
|
||||
//! This module defines the typed `PttCapabilityLevel` enum and a
|
||||
//! This module defines the typed `PttCapabilityLevel` enum, the
|
||||
//! lightweight `PttBackendDescriptor` value the audio engine
|
||||
//! publishes to upstream consumers so the UI can render the live
|
||||
//! capability badge (SDD-091) and the release verification record
|
||||
//! can carry per-platform evidence (SysDes-148).
|
||||
//! publishes to upstream consumers, the `AudioTransmitGate` object
|
||||
//! that owns the authoritative `transmit_active` flag (SAD-075 /
|
||||
//! SDD-089), and the `MissedKeyUpWatchdog` task (SAD-079 / SDD-092
|
||||
//! / DEC-028).
|
||||
//!
|
||||
//! Concrete platform backends (`WindowsRawInputBackend`,
|
||||
//! `MacOSEventTapBackend`, `LinuxGnomeWaylandBackend`) are deferred
|
||||
//! to a follow-up code milestone; this commit lands the trait shape
|
||||
//! and the universal `FocusedPttBackend` constant value so the
|
||||
//! current Flutter-side hold-to-talk widget reports its capability
|
||||
//! honestly through the bridge.
|
||||
//! `MacOSEventTapBackend`, `LinuxGnomeWaylandBackend`) live in the
|
||||
//! sibling `backends` module; this file holds the cross-platform
|
||||
//! pieces.
|
||||
|
||||
use core::fmt;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::watch;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Detected runtime capability of the active desktop PTT backend.
|
||||
///
|
||||
@@ -121,6 +126,172 @@ impl Default for PttBackendDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the authoritative `transmit_active` AtomicBool plus a
|
||||
/// `tokio::sync::watch` channel so subscribers (notably the
|
||||
/// missed-key-up watchdog) can observe transitions without
|
||||
/// polling. Per SAD-075 / SDD-089 this is the **only** mutator
|
||||
/// path on `transmit_active`; the encoder feed and the platform
|
||||
/// PTT backends call `set` and `load` exclusively.
|
||||
///
|
||||
/// Cheap to clone — internally an `Arc` over the atomic and the
|
||||
/// watch sender.
|
||||
#[derive(Clone)]
|
||||
pub struct AudioTransmitGate {
|
||||
inner: Arc<TransmitGateInner>,
|
||||
}
|
||||
|
||||
struct TransmitGateInner {
|
||||
flag: Arc<AtomicBool>,
|
||||
tx: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl AudioTransmitGate {
|
||||
/// Construct a new gate initialised to `initial`.
|
||||
pub fn new(initial: bool) -> Self {
|
||||
let (tx, _rx) = watch::channel(initial);
|
||||
Self {
|
||||
inner: Arc::new(TransmitGateInner {
|
||||
flag: Arc::new(AtomicBool::new(initial)),
|
||||
tx,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the transmit flag. When the value changes, every
|
||||
/// `watch::Receiver` returned by [`Self::subscribe`] observes
|
||||
/// the new value.
|
||||
pub fn set(&self, active: bool) {
|
||||
// We always store on the atomic (it's the hot read path
|
||||
// for the encoder feed) and we always send on the watch
|
||||
// channel — the watch implementation deduplicates so a
|
||||
// repeat `set(true)` does not wake subscribers.
|
||||
self.inner.flag.store(active, Ordering::Relaxed);
|
||||
let _ = self.inner.tx.send(active);
|
||||
}
|
||||
|
||||
/// Read the current value. Cheap; no locking.
|
||||
pub fn load(&self) -> bool {
|
||||
self.inner.flag.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Subscribe to value transitions. The returned receiver
|
||||
/// observes every distinct transition (the watch channel
|
||||
/// deduplicates same-value sends).
|
||||
pub fn subscribe(&self) -> watch::Receiver<bool> {
|
||||
self.inner.tx.subscribe()
|
||||
}
|
||||
|
||||
/// Live shared handle to the underlying `AtomicBool` for hot
|
||||
/// paths that need to consult the flag once per outbound audio
|
||||
/// frame without going through the gate wrappers. Callers must
|
||||
/// treat the returned Arc as read-only; the gate's `set`
|
||||
/// remains the sole mutator (SAD-075).
|
||||
pub fn flag_arc(&self) -> Arc<AtomicBool> {
|
||||
self.inner.flag.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task that clears `transmit_active` after a configured
|
||||
/// timeout (SAD-079 / SDD-092 / DEC-028).
|
||||
///
|
||||
/// Subscribes to the gate's watch channel, notes the timestamp of
|
||||
/// each `false -> true` transition, clears the timestamp on each
|
||||
/// `true -> false` transition. If the `true` lifetime exceeds the
|
||||
/// configured ceiling the task self-clears the gate to false and
|
||||
/// emits a sanitised diagnostic record (no key data — DEC-027).
|
||||
pub struct MissedKeyUpWatchdog {
|
||||
handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl MissedKeyUpWatchdog {
|
||||
/// Default timeout per DEC-028 — 30 seconds.
|
||||
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Spawn the watchdog. The returned handle aborts the task on
|
||||
/// Drop. The watchdog needs a tokio runtime context; the audio
|
||||
/// engine spawns it inside `start_audio` where the bridge's
|
||||
/// runtime is available.
|
||||
pub fn spawn(gate: AudioTransmitGate, timeout: Duration) -> Self {
|
||||
let handle = tokio::spawn(async move {
|
||||
run_watchdog(gate, timeout).await;
|
||||
});
|
||||
Self {
|
||||
handle: Some(handle),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MissedKeyUpWatchdog {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.handle.take() {
|
||||
h.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_watchdog(gate: AudioTransmitGate, timeout: Duration) {
|
||||
let mut rx = gate.subscribe();
|
||||
// Track whether we are currently in a `transmit_active = true`
|
||||
// window. We don't need the actual timestamp; we just need a
|
||||
// bounded wait that wakes on either the gate going false or
|
||||
// the timeout elapsing.
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
timeout_secs = timeout.as_secs(),
|
||||
"missed-key-up watchdog spawned"
|
||||
);
|
||||
loop {
|
||||
// Wait for a transition.
|
||||
if rx.changed().await.is_err() {
|
||||
// Gate dropped; exit.
|
||||
return;
|
||||
}
|
||||
let is_active = *rx.borrow_and_update();
|
||||
if !is_active {
|
||||
// Either the user released, or someone else cleared
|
||||
// the gate. Either way the watchdog has nothing to
|
||||
// do until the next press.
|
||||
continue;
|
||||
}
|
||||
// The gate just went true. Wait for either a release
|
||||
// transition or for the timeout to elapse.
|
||||
let release_or_timeout = tokio::select! {
|
||||
r = rx.changed() => r.map(|_| *rx.borrow_and_update()),
|
||||
_ = tokio::time::sleep(timeout) => {
|
||||
// Timeout — self-clear and emit the privacy-safe
|
||||
// diagnostic record. The field set here is
|
||||
// deliberately limited to the values the gen2
|
||||
// sanitizer permits (DEC-027): the active
|
||||
// capability descriptor is owned by the controller
|
||||
// and is not in scope here, so we emit only the
|
||||
// watchdog-relevant fact.
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
timeout_secs = timeout.as_secs(),
|
||||
"missed-key-up watchdog fired; clearing transmit_active"
|
||||
);
|
||||
gate.set(false);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match release_or_timeout {
|
||||
Ok(false) => {
|
||||
// Normal release; loop and wait for the next press.
|
||||
}
|
||||
Ok(true) => {
|
||||
// Edge case: same-value double-set. Watch
|
||||
// deduplicates so this should not actually fire,
|
||||
// but we keep waiting on the timeout for the
|
||||
// next iteration.
|
||||
}
|
||||
Err(_) => {
|
||||
// Channel closed; exit.
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -158,4 +329,57 @@ mod tests {
|
||||
let _: &str = d.backend_id;
|
||||
let _: Option<&str> = d.bound_input_class;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_set_and_load_roundtrip() {
|
||||
let g = AudioTransmitGate::new(false);
|
||||
assert!(!g.load());
|
||||
g.set(true);
|
||||
assert!(g.load());
|
||||
g.set(false);
|
||||
assert!(!g.load());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread", start_paused = true)]
|
||||
async fn gate_subscribe_observes_transitions() {
|
||||
let g = AudioTransmitGate::new(false);
|
||||
let mut rx = g.subscribe();
|
||||
g.set(true);
|
||||
rx.changed().await.unwrap();
|
||||
assert!(*rx.borrow_and_update());
|
||||
g.set(false);
|
||||
rx.changed().await.unwrap();
|
||||
assert!(!*rx.borrow_and_update());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn watchdog_clears_transmit_after_timeout() {
|
||||
let g = AudioTransmitGate::new(false);
|
||||
let _wd = MissedKeyUpWatchdog::spawn(g.clone(), Duration::from_millis(80));
|
||||
// Give the watchdog a tick to subscribe before we press.
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
g.set(true);
|
||||
// Wait past the timeout. The watchdog runs on a separate
|
||||
// worker so real-time elapses in parallel.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert!(!g.load(), "watchdog should have cleared transmit_active");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn watchdog_does_not_clear_on_normal_release() {
|
||||
let g = AudioTransmitGate::new(false);
|
||||
let _wd = MissedKeyUpWatchdog::spawn(g.clone(), Duration::from_millis(80));
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
g.set(true);
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
g.set(false);
|
||||
// Wait past what would have been the timeout.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert!(!g.load());
|
||||
// The gate should still be `false` and the next press
|
||||
// should re-arm the watchdog cleanly.
|
||||
g.set(true);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert!(!g.load(), "watchdog should fire on the second press as well");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user