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:
@@ -33,3 +33,18 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||
# by the bridge crate's android_init shim.
|
||||
jni = { version = "0.21", default-features = false }
|
||||
ndk-context = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
# `test-util` enables `start_paused` / virtual-clock tests used by
|
||||
# the missed-key-up watchdog unit tests.
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros", "time", "test-util"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# GNOME-on-Wayland Global Push-to-Talk uses the freedesktop
|
||||
# `org.freedesktop.portal.GlobalShortcuts` interface over D-Bus.
|
||||
# `zbus` is the standard async D-Bus crate; we use the blocking
|
||||
# proxy for the probe path since portal version reads happen at
|
||||
# audio-engine start (off the hot path). The `tokio` runtime
|
||||
# selector is mandatory in zbus 5; we share the tokio runtime
|
||||
# the rest of the audio + core crates already depend on.
|
||||
zbus = { version = "5", default-features = false, features = ["tokio", "blocking-api"] }
|
||||
|
||||
@@ -83,7 +83,7 @@ pub struct AudioEngine {
|
||||
/// Windows / macOS / Linux global backends) drive this flag
|
||||
/// through [`Self::set_transmit_active`]; nothing else is
|
||||
/// permitted to flip it (SAD-075 / SDD-089).
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
transmit_gate: crate::ptt::AudioTransmitGate,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
frames_received: Arc<AtomicU32>,
|
||||
/// Master output gain as f32 bits in an AtomicU32. Default 1.0.
|
||||
@@ -108,6 +108,14 @@ pub struct AudioEngine {
|
||||
/// denied microphone permission), PTT becomes a no-op and
|
||||
/// `frames_sent` stays at 0.
|
||||
capture_active: bool,
|
||||
/// Active desktop PTT backend (SAD-071 / SDD-081). Stored
|
||||
/// inside a `Mutex<Option<_>>` so `stop()` can move it out
|
||||
/// and release OS-level resources before the engine is
|
||||
/// dropped. The value is always `Some` between `start_audio`
|
||||
/// and `stop`.
|
||||
ptt_backend: Mutex<Option<Box<dyn crate::ptt_backends::DesktopPttBackend>>>,
|
||||
/// Missed-key-up watchdog. Dropping aborts the task.
|
||||
ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog>,
|
||||
}
|
||||
|
||||
// cpal::Stream is not Send. We keep the engine pinned to the thread
|
||||
@@ -187,7 +195,8 @@ impl AudioEngine {
|
||||
}
|
||||
}
|
||||
|
||||
let transmit_active = Arc::new(AtomicBool::new(cfg.ptt_initial));
|
||||
let transmit_gate = crate::ptt::AudioTransmitGate::new(cfg.ptt_initial);
|
||||
let transmit_flag_for_capture = transmit_gate.flag_arc();
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
let frames_received = Arc::new(AtomicU32::new(0));
|
||||
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
||||
@@ -202,7 +211,7 @@ impl AudioEngine {
|
||||
let capture_result = try_open_capture(
|
||||
&in_dev,
|
||||
voice_out_tx,
|
||||
transmit_active.clone(),
|
||||
transmit_flag_for_capture,
|
||||
frames_sent.clone(),
|
||||
cfg.mic_gain,
|
||||
);
|
||||
@@ -298,8 +307,44 @@ impl AudioEngine {
|
||||
}
|
||||
});
|
||||
|
||||
// Select and arm the desktop PTT backend (SAD-071,
|
||||
// SDD-081). This call is the only place that talks to the
|
||||
// platform-input layer; the rest of the engine consumes
|
||||
// the typed `AudioTransmitGate`. We always have a backend
|
||||
// because the cross-platform factory falls back to
|
||||
// `FocusedPttBackend` (SDD-087).
|
||||
let mut ptt_backend = crate::ptt_backends::select();
|
||||
let initial_binding = crate::ptt_backends::PttBinding::none();
|
||||
match ptt_backend.start(transmit_gate.clone(), initial_binding) {
|
||||
Ok(()) => {
|
||||
let d = ptt_backend.descriptor();
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
capability_level = %d.level,
|
||||
backend_id = d.backend_id,
|
||||
bound_input_class = ?d.bound_input_class,
|
||||
"ptt backend armed"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"ptt backend start failed; engine continues with Focused fallback"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn the missed-key-up watchdog. The task aborts on
|
||||
// Drop of `MissedKeyUpWatchdog`, so the engine's `stop`
|
||||
// / Drop chain releases it without explicit cleanup.
|
||||
let ptt_watchdog = crate::ptt::MissedKeyUpWatchdog::spawn(
|
||||
transmit_gate.clone(),
|
||||
crate::ptt::MissedKeyUpWatchdog::DEFAULT_TIMEOUT,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
transmit_active,
|
||||
transmit_gate,
|
||||
frames_sent,
|
||||
frames_received,
|
||||
output_gain,
|
||||
@@ -308,6 +353,8 @@ impl AudioEngine {
|
||||
_output_stream: Mutex::new(Some(output_stream)),
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
capture_active,
|
||||
ptt_backend: Mutex::new(Some(ptt_backend)),
|
||||
ptt_watchdog: Some(ptt_watchdog),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -316,6 +363,17 @@ impl AudioEngine {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
// Release the active PTT backend's OS resources before
|
||||
// dropping the streams; the backend may hold a worker
|
||||
// thread (Raw Input message loop, Event Tap run loop, etc.)
|
||||
// that needs an explicit stop() to wind down cleanly.
|
||||
if let Ok(mut guard) = self.ptt_backend.lock() {
|
||||
if let Some(mut backend) = guard.take() {
|
||||
backend.stop();
|
||||
}
|
||||
}
|
||||
// Aborting the watchdog cancels its tokio task.
|
||||
self.ptt_watchdog.take();
|
||||
// Drop the streams, which stops their callback threads.
|
||||
let _ = self._input_stream.lock().unwrap().take();
|
||||
let _ = self._output_stream.lock().unwrap().take();
|
||||
@@ -330,12 +388,53 @@ impl AudioEngine {
|
||||
/// per-platform global backends in a follow-up — call this
|
||||
/// method exclusively. No-op when capture is inactive.
|
||||
pub fn set_transmit_active(&self, active: bool) {
|
||||
self.transmit_active.store(active, Ordering::Relaxed);
|
||||
self.transmit_gate.set(active);
|
||||
}
|
||||
|
||||
/// Current transmit gate state.
|
||||
pub fn transmit_active(&self) -> bool {
|
||||
self.transmit_active.load(Ordering::Relaxed)
|
||||
self.transmit_gate.load()
|
||||
}
|
||||
|
||||
/// Shared handle to the underlying transmit gate (SAD-075 /
|
||||
/// SDD-089). Returned for diagnostics and integration tests
|
||||
/// only; never mutate the underlying atomic directly — use
|
||||
/// [`Self::set_transmit_active`] instead.
|
||||
pub fn transmit_gate(&self) -> &crate::ptt::AudioTransmitGate {
|
||||
&self.transmit_gate
|
||||
}
|
||||
|
||||
/// Privacy-safe descriptor of the currently active PTT
|
||||
/// backend (SDD-081 / SDD-091). Returns the universal Focused
|
||||
/// fallback descriptor when the backend slot is empty
|
||||
/// (typically only between `stop()` and Drop).
|
||||
pub fn ptt_descriptor(&self) -> crate::ptt::PttBackendDescriptor {
|
||||
if let Ok(guard) = self.ptt_backend.lock() {
|
||||
if let Some(b) = guard.as_ref() {
|
||||
return b.descriptor();
|
||||
}
|
||||
}
|
||||
crate::ptt::PttBackendDescriptor::focused()
|
||||
}
|
||||
|
||||
/// Replace the PTT binding on the active backend. Returns the
|
||||
/// freshly-published descriptor so callers can re-emit the
|
||||
/// capability event.
|
||||
pub fn rebind_ptt(
|
||||
&self,
|
||||
binding: crate::ptt_backends::PttBinding,
|
||||
) -> Result<crate::ptt::PttBackendDescriptor, AudioError> {
|
||||
let mut guard = self
|
||||
.ptt_backend
|
||||
.lock()
|
||||
.map_err(|_| AudioError::Backend("ptt_backend mutex poisoned".to_string()))?;
|
||||
let backend = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| AudioError::Backend("ptt backend not armed".to_string()))?;
|
||||
backend
|
||||
.rebind(binding)
|
||||
.map_err(|e| AudioError::Backend(format!("rebind: {e}")))?;
|
||||
Ok(backend.descriptor())
|
||||
}
|
||||
|
||||
/// Legacy alias for [`Self::set_transmit_active`]. Retained so
|
||||
|
||||
@@ -30,9 +30,16 @@
|
||||
|
||||
mod engine;
|
||||
pub mod ptt;
|
||||
pub mod ptt_backends;
|
||||
|
||||
pub use engine::{AudioEngine, AudioEngineConfig};
|
||||
pub use ptt::{PttCapabilityLevel, PttBackendDescriptor};
|
||||
pub use ptt::{
|
||||
AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel,
|
||||
};
|
||||
pub use ptt_backends::{
|
||||
select as select_ptt_backend, DesktopPttBackend, FocusedPttBackend, PttBackendError,
|
||||
PttBinding, PttInputClass,
|
||||
};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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