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