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:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user