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
+53
View File
@@ -255,6 +255,59 @@ pub async fn set_ptt(active: bool) -> Result<(), BridgeError> {
Ok(())
}
/// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings;
/// the bridge never carries raw key codes.
#[derive(Debug, Clone, Copy)]
pub enum BridgePttInputClass {
/// No binding is active.
None,
/// A keyboard key.
Keyboard,
/// A mouse side button (Mouse4 / Mouse5).
MouseSideButton,
}
impl From<BridgePttInputClass> for chanora_core::PttInputClass {
fn from(c: BridgePttInputClass) -> Self {
match c {
BridgePttInputClass::None => chanora_core::PttInputClass::None,
BridgePttInputClass::Keyboard => chanora_core::PttInputClass::Keyboard,
BridgePttInputClass::MouseSideButton => chanora_core::PttInputClass::MouseSideButton,
}
}
}
/// Update the active PTT binding (gen2 v0.9.3 / DEC-026). The
/// platform_key string is opaque to the bridge — it identifies the
/// bound key inside the platform backend and never appears in any
/// log record or diagnostic export (DEC-027 enforced at the
/// diagnostics-sanitizer layer).
pub async fn set_ptt_binding(
input_class: BridgePttInputClass,
platform_key: String,
) -> Result<(), BridgeError> {
let binding = chanora_core::PttBinding {
input_class: input_class.into(),
platform_key,
};
runtime()
.spawn(async move { session().set_ptt_binding(binding).await })
.await
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
Ok(())
}
/// Read the current PTT capability descriptor. Returns a
/// `(level, backend_id, bound_input_class)` triple matching the
/// privacy-safe `BridgeEvent::PttCapability` event shape; useful
/// for the initial UI render before the first event arrives.
pub async fn ptt_descriptor() -> (String, String, String) {
runtime()
.spawn(async { session().ptt_descriptor().await })
.await
.unwrap_or_else(|_| (String::new(), String::new(), String::new()))
}
/// Move our own client to `channel_id`. Optional channel password
/// for password-protected channels — pass an empty string when not
/// required.
+155 -9
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 663465485;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -427953414;
// Section: executor
@@ -470,6 +470,41 @@ fn wire__crate__api__move_to_channel_impl(
},
)
}
fn wire__crate__api__ptt_descriptor_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "ptt_descriptor",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| async move {
transform_result_sse::<_, ()>(
(move || async move {
let output_ok = Result::<_, ()>::Ok(crate::api::ptt_descriptor().await)?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__set_input_muted_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -646,6 +681,44 @@ fn wire__crate__api__set_ptt_impl(
},
)
}
fn wire__crate__api__set_ptt_binding_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "set_ptt_binding",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_input_class = <crate::api::BridgePttInputClass>::sse_decode(&mut deserializer);
let api_platform_key = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok =
crate::api::set_ptt_binding(api_input_class, api_platform_key).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__snapshot_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -959,6 +1032,19 @@ impl SseDecode for crate::api::BridgeNetworkState {
}
}
impl SseDecode for crate::api::BridgePttInputClass {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <i32>::sse_decode(deserializer);
return match inner {
0 => crate::api::BridgePttInputClass::None,
1 => crate::api::BridgePttInputClass::Keyboard,
2 => crate::api::BridgePttInputClass::MouseSideButton,
_ => unreachable!("Invalid variant for BridgePttInputClass: {}", inner),
};
}
}
impl SseDecode for crate::api::BridgeSnapshot {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1048,6 +1134,16 @@ impl SseDecode for Vec<u8> {
}
}
impl SseDecode for (String, String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_field0 = <String>::sse_decode(deserializer);
let mut var_field1 = <String>::sse_decode(deserializer);
let mut var_field2 = <String>::sse_decode(deserializer);
return (var_field0, var_field1, var_field2);
}
}
impl SseDecode for u32 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1094,13 +1190,15 @@ fn pde_ffi_dispatcher_primary_impl(
10 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
18 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
18 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -1114,7 +1212,7 @@ fn pde_ffi_dispatcher_sync_impl(
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id {
8 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
14 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
15 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -1303,6 +1401,28 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeNetworkState>
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgePttInputClass {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
Self::None => 0.into_dart(),
Self::Keyboard => 1.into_dart(),
Self::MouseSideButton => 2.into_dart(),
_ => unreachable!(),
}
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgePttInputClass
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgePttInputClass>
for crate::api::BridgePttInputClass
{
fn into_into_dart(self) -> crate::api::BridgePttInputClass {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeSnapshot {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@@ -1495,6 +1615,23 @@ impl SseEncode for crate::api::BridgeNetworkState {
}
}
impl SseEncode for crate::api::BridgePttInputClass {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i32>::sse_encode(
match self {
crate::api::BridgePttInputClass::None => 0,
crate::api::BridgePttInputClass::Keyboard => 1,
crate::api::BridgePttInputClass::MouseSideButton => 2,
_ => {
unimplemented!("");
}
},
serializer,
);
}
}
impl SseEncode for crate::api::BridgeSnapshot {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -1568,6 +1705,15 @@ impl SseEncode for Vec<u8> {
}
}
impl SseEncode for (String, String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.0, serializer);
<String>::sse_encode(self.1, serializer);
<String>::sse_encode(self.2, serializer);
}
}
impl SseEncode for u32 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {