From 82d012a46b810b0bde9246434b22752c9e77e0b3 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Fri, 15 May 2026 16:43:45 +0800 Subject: [PATCH] feat(ptt): live Linux GNOME-Wayland portal session flow (DEC-025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the Linux backend from probe-only to a live `org.freedesktop.portal.GlobalShortcuts` session, closing the gen2 v0.9.3 baseline's last Linux-side code item. Both gaps I flagged on the review pass are addressed: * Stop now closes the portal session through the dedicated `org.freedesktop.portal.Session` interface (not the request-cancel `Request` interface — that would only abort a pending Request, not release the bound shortcuts). * Ten new unit tests cover `classify_shortcuts_value`, `publish_bound`, `publish_l0`, and the `SHORTCUT_ID` stability contract using synthesised `OwnedValue` payloads. Live D-Bus coverage stays in the `linux_portal_smoke` ignored integration test (RR-PTT-004). Live session lifecycle (gen2 Q5b — lazy, single backend instance): 1. `start(gate, binding)` spawns one `tokio::spawn` worker that owns an async `zbus::Connection` (sharing the bridge's tokio runtime per Q4a). 2. `CreateSession` with fresh random `handle_token` / `session_handle_token` tokens. The worker awaits the portal `Response` signal via a `RequestProxy` subscription and extracts `session_handle` from the results dict. 3. `BindShortcuts(session_handle, [("chanora-ptt", { description = "Chanora push-to-talk" })], "", {})`. The portal opens its own system-managed dialog asking the user to choose a key — Chanora itself never reads raw key events. The audio engine continues at `L0Focused` while the dialog is open; the descriptor watch publishes the transition once the portal returns. 4. On `response_code == 0`: classify the `trigger_description` substring (heuristic: contains "mouse" -> MouseSideButton, else Keyboard), publish `L2GlobalHoldToTalk` (or `L3` for mouse) through the watch sender. The raw trigger_description string is never logged (DEC-027 / SRS-202). 5. On `response_code == 1` (cancelled) or `>= 2` (failure): publish `L0Focused` through the watch sender. The user can retry via the UI "Configure" button (gen2 Q6a). 6. The worker enters a `tokio::select!` loop multiplexing the `cmd_rx` channel (Rebind / Stop) and the `Activated` / `Deactivated` signals. Matching signals scoped to this session handle and `chanora-ptt` shortcut id drive `gate.set(true/false)`. 7. `Rebind` re-runs `BindShortcuts` on the same session. 8. `Stop` calls `org.freedesktop.portal.Session.Close()` on the session-handle object path, clears the gate, exits. UX (gen2 Q3a): when `_pttBackendId == 'gnome-wayland-portal'`, the Flutter "Configure" button skips the in-app `_PttBindingCaptureDialog` and shows a SnackBar telling the user their desktop environment will open its own shortcut dialog. The button delegates to `setPttBinding(keyboard, "portal")` which nudges the backend; the portal handles the rest. New ARB key `pttConfigurePortalRedirect` in en + zh-Hans. Trait surface (cross-cutting): * `DesktopPttBackend::descriptor_watch()` is a new trait method with a default impl returning a never-firing receiver. Backends with async capability transitions (only the Linux portal backend today) override it to return the live watch sender's receiver. * `chanora_core::ChanoraSession::start_audio` subscribes to the active backend's `descriptor_watch()` and spawns a forwarder task that re-emits `SessionEvent::PttCapability` on every transition. The initial value is emitted synchronously. `Cargo.toml` (Linux-only): * `futures-util` (std features, no executor) for stream consumption on the portal signal subscriptions. * `rand 0.8` for fresh per-process portal tokens. * `zbus` continues at v5 with the `tokio` + `blocking-api` features. Tests ----- * `chanora_audio` rises from 8 to 18 unit tests. New coverage on the Linux module: - `classify_returns_none_when_shortcut_id_missing` - `classify_returns_keyboard_for_typical_trigger_description` - `classify_returns_keyboard_when_trigger_description_missing` - `classify_detects_mouse_substring` - `classify_is_case_insensitive_on_mouse_substring` - `publish_bound_keyboard_publishes_L2_with_keyboard_class` - `publish_bound_mouse_publishes_L3` - `publish_bound_none_publishes_L2_keyboard_default` - `publish_l0_clears_descriptor` - `shortcut_id_is_stable` * Workspace total: 67 unit + integration tests, all green with `CHANORA_DISABLE_KEYRING=1` (was 57 at v1.0.0-rc.4). * New `crates/chanora_audio/tests/linux_portal_smoke.rs` ignored integration test (RR-PTT-004 evidence path). Run on a GNOME-on-Wayland host with `cargo test -p chanora_audio --test linux_portal_smoke -- --ignored --nocapture`. Documentation ------------- * `docs/architecture/desktop-ptt-architecture.md` §5.3 rewritten to describe the realised lifecycle; v0.9.4 change-history entry added. * `docs/governance/product-decision-register.md` v0.9.10 change-history entry recording the code-side promotion. No decision rows mutate. * `docs/release/release-readiness-go-nogo-record.md` RR-PTT-004 flipped from `Open` to `Implemented (live trace pending)`; v0.9.5 change-history entry. Verification ------------ * `cargo test --workspace`: 67/67 green. * `cargo deny check`: advisories ok, bans ok, licenses ok, sources ok. * `cargo about generate --offline`: zero new warnings. * `tools/dump_flutter_licenses.sh`: 94 packages, 0 without LICENSE. * `flutter analyze`: clean. * `cargo build -p chanora_bridge --release` + `flutter build linux --release`: clean Linux x86_64 bundle. * Live portal trace (RR-PTT-004) — **not run**. The dev shell is a TTY without a Wayland session. The user will run the ignored smoke test from inside a GNOME-on-Wayland session when available. No Windows / macOS / iOS live verification in this commit (hosts unavailable). The Windows + macOS backend scaffolds remain in place reporting their target capability honestly; live OS-call wiring is queued for their respective platform owners' reference hosts per `docs/governance/staged-release-plan.md`. --- Cargo.lock | 2 + apps/chanora_flutter/lib/l10n/app_en.arb | 1 + apps/chanora_flutter/lib/l10n/app_zh.arb | 1 + .../lib/l10n/generated/app_localizations.dart | 6 + .../l10n/generated/app_localizations_en.dart | 4 + .../l10n/generated/app_localizations_zh.dart | 3 + apps/chanora_flutter/lib/main.dart | 35 +- core/chanora_core/src/lib.rs | 40 +- crates/chanora_audio/Cargo.toml | 17 +- crates/chanora_audio/src/engine.rs | 13 + .../chanora_audio/src/ptt_backends/linux.rs | 768 ++++++++++++++++-- crates/chanora_audio/src/ptt_backends/mod.rs | 21 + .../chanora_audio/tests/linux_portal_smoke.rs | 42 + docs/architecture/desktop-ptt-architecture.md | 27 +- docs/governance/product-decision-register.md | 1 + .../release-readiness-go-nogo-record.md | 3 +- 16 files changed, 897 insertions(+), 87 deletions(-) create mode 100644 crates/chanora_audio/tests/linux_portal_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index 24c344a..bf356d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -380,8 +380,10 @@ dependencies = [ "audiopus", "chanora_protocol", "cpal", + "futures-util", "jni 0.21.1", "ndk-context", + "rand 0.8.6", "thiserror 2.0.18", "tokio", "tracing", diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index 7f27346..bbd6900 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -46,6 +46,7 @@ "pttConfigureCaptured": "Captured", "pttConfigurePrivacyNote": "Chanora never logs the actual key value. Only the input class (keyboard / mouse-side-button) and a platform-neutral label leave this dialog.", "pttConfigureSaveAction": "Save", + "pttConfigurePortalRedirect": "Your desktop environment will open its own shortcut dialog. Pick the key you want to use for Push-to-Talk.", "inputMuteAction": "Mute mic", "inputUnmuteAction": "Unmute mic", "outputMuteAction": "Mute speaker", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 404316f..79eb567 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -36,6 +36,7 @@ "pttConfigureCaptured": "已捕获", "pttConfigurePrivacyNote": "Chanora 不会记录具体的按键值。本对话框只会向应用提交输入类别(键盘 / 鼠标侧键)以及一个跨平台标签。", "pttConfigureSaveAction": "保存", + "pttConfigurePortalRedirect": "您的桌面环境将打开自带的快捷键对话框,请在其中选择用于对讲的按键。", "inputMuteAction": "静音麦克风", "inputUnmuteAction": "取消麦克风静音", "outputMuteAction": "静音扬声器", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index 86419a9..a2752e1 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -289,6 +289,12 @@ abstract class AppL10n { /// **'Save'** String get pttConfigureSaveAction; + /// No description provided for @pttConfigurePortalRedirect. + /// + /// In en, this message translates to: + /// **'Your desktop environment will open its own shortcut dialog. Pick the key you want to use for Push-to-Talk.'** + String get pttConfigurePortalRedirect; + /// No description provided for @inputMuteAction. /// /// In en, this message translates to: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index 9e79a59..2b356c8 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -115,6 +115,10 @@ class AppL10nEn extends AppL10n { @override String get pttConfigureSaveAction => 'Save'; + @override + String get pttConfigurePortalRedirect => + 'Your desktop environment will open its own shortcut dialog. Pick the key you want to use for Push-to-Talk.'; + @override String get inputMuteAction => 'Mute mic'; diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index b7b9715..0d25b06 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -112,6 +112,9 @@ class AppL10nZh extends AppL10n { @override String get pttConfigureSaveAction => '保存'; + @override + String get pttConfigurePortalRedirect => '您的桌面环境将打开自带的快捷键对话框,请在其中选择用于对讲的按键。'; + @override String get inputMuteAction => '静音麦克风'; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 18628ff..16de00f 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -392,13 +392,38 @@ class _BetaHomeState extends State<_BetaHome> { } Future _onConfigurePtt(BuildContext context) async { - // Open a focus-scoped dialog that captures the next key press - // and submits it to the platform backend as the new PTT - // binding. The bridge carries only the coarse input class and - // an opaque platform-key string; the actual key value never - // appears in any log record (DEC-027 / SRS-202). + // On the Linux GNOME-Wayland portal backend, the portal hosts + // its own system-managed binding dialog (gen2 v0.9.3 / Q3a). + // Skip the in-app capture dialog entirely on that backend and + // delegate to the portal via `setPttBinding` with a sentinel + // platform_key. Show a SnackBar so the user isn't surprised + // when their compositor opens a separate dialog. final l10n = AppL10n.of(context); if (!mounted) return; + if (_pttBackendId == 'gnome-wayland-portal') { + try { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar(SnackBar( + content: Text(l10n.pttConfigurePortalRedirect), + )); + await rust.setPttBinding( + inputClass: rust.BridgePttInputClass.keyboard, + platformKey: 'portal', + ); + } catch (e) { + if (!mounted) return; + final messenger = ScaffoldMessenger.of(this.context); + messenger.showSnackBar(SnackBar( + content: Text(l10n.statusError(e.toString())), + )); + } + return; + } + + // Other backends: open the in-app focus-scoped capture + // dialog. The bridge carries only the coarse input class and + // an opaque platform-key string; the actual key value never + // appears in any log record (DEC-027 / SRS-202). final binding = await showDialog<_CapturedBinding>( context: context, builder: (ctx) => const _PttBindingCaptureDialog(), diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 1fb92a0..c01a0d0 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -456,17 +456,39 @@ impl ChanoraSession { } let _ = self.events_tx.send(SessionEvent::AudioStarted); // Publish the current PTT capability so the UI badge can - // render an honest value (SRS-196 / SDD-091). The current - // milestone ships only the universal Focused backend - // (PTT-L0); per-platform global backends arrive in a - // follow-up code commit. The descriptor is privacy-safe - // by construction (DEC-027). - let desc = PttBackendDescriptor::focused(); + // render an honest value (SRS-196 / SDD-091). Each + // platform backend reports its actual runtime capability + // through its descriptor; the universal Focused fallback + // reports `L0Focused`. + // + // The engine itself owns the active backend, so we ask it + // for the live value rather than synthesising a focused + // descriptor here. We also subscribe to descriptor + // transitions and spawn a forwarder task: backends that + // resolve their capability asynchronously (notably the + // Linux portal backend after the user accepts the + // BindShortcuts dialog) re-publish through this watcher. + let initial_desc = state.audio.as_ref().map(|a| a.ptt_descriptor()).unwrap_or_else( + PttBackendDescriptor::focused, + ); let _ = self.events_tx.send(SessionEvent::PttCapability { - level: desc.level.as_str().to_string(), - backend_id: desc.backend_id.to_string(), - bound_input_class: desc.bound_input_class.unwrap_or("").to_string(), + level: initial_desc.level.as_str().to_string(), + backend_id: initial_desc.backend_id.to_string(), + bound_input_class: initial_desc.bound_input_class.unwrap_or("").to_string(), }); + if let Some(mut watch_rx) = state.audio.as_ref().and_then(|a| a.ptt_descriptor_watch()) { + let events_tx = self.events_tx.clone(); + tokio::spawn(async move { + while watch_rx.changed().await.is_ok() { + let d = watch_rx.borrow_and_update().clone(); + let _ = events_tx.send(SessionEvent::PttCapability { + level: d.level.as_str().to_string(), + backend_id: d.backend_id.to_string(), + bound_input_class: d.bound_input_class.unwrap_or("").to_string(), + }); + } + }); + } Ok(()) } diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml index 758730c..dff7a33 100644 --- a/crates/chanora_audio/Cargo.toml +++ b/crates/chanora_audio/Cargo.toml @@ -42,9 +42,18 @@ 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 +# `zbus` is the standard async D-Bus crate; the `tokio` runtime # selector is mandatory in zbus 5; we share the tokio runtime -# the rest of the audio + core crates already depend on. +# the rest of the audio + core crates already depend on. The +# `blocking-api` feature is retained so the audio-engine +# probe path can do a synchronous portal-version read without +# starting an async runtime; the live session flow uses the +# async surface. zbus = { version = "5", default-features = false, features = ["tokio", "blocking-api"] } +# Stream / sink utilities for consuming portal signals on the +# async path. +futures-util = { version = "0.3", default-features = false, features = ["std"] } +# Random token bytes for the portal handle_token / session_handle_token +# options. The portal recommends fresh tokens to scope its own +# object paths per call. +rand = "0.8" diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index abfb4cc..c7390b6 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -437,6 +437,19 @@ impl AudioEngine { Ok(backend.descriptor()) } + /// Subscribe to PTT descriptor transitions on the active + /// backend. The Linux GNOME-Wayland portal backend uses this + /// channel to publish the post-`BindShortcuts` capability + /// transition. Returns `None` when the engine has no backend + /// armed (e.g. between `stop()` and Drop). + pub fn ptt_descriptor_watch( + &self, + ) -> Option> { + let guard = self.ptt_backend.lock().ok()?; + let backend = guard.as_ref()?; + Some(backend.descriptor_watch()) + } + /// Legacy alias for [`Self::set_transmit_active`]. Retained so /// the existing bridge `set_ptt` command and the existing /// Flutter UI continue to compile during the v0.9.3 PTT diff --git a/crates/chanora_audio/src/ptt_backends/linux.rs b/crates/chanora_audio/src/ptt_backends/linux.rs index af9e446..68146e5 100644 --- a/crates/chanora_audio/src/ptt_backends/linux.rs +++ b/crates/chanora_audio/src/ptt_backends/linux.rs @@ -1,4 +1,5 @@ -//! Linux desktop PTT backend (SDD-086). +//! Linux desktop PTT backend (SDD-086) — live `GlobalShortcuts` +//! portal session flow. //! //! Officially-tested target per DEC-025 is **GNOME on Wayland**. //! On that environment we use the freedesktop @@ -10,16 +11,43 @@ //! 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`. +//! +//! Lifecycle (Q5b — lazy session, single backend instance): +//! +//! * `try_select` runs a synchronous portal-version probe on +//! the blocking proxy. Failure → `None` → Focused fallback. +//! * `start(gate, binding)` spawns a worker tokio task that +//! owns an async `zbus::Connection`, calls `CreateSession`, +//! then `BindShortcuts` (sentinel id `"chanora-ptt"`). The +//! portal opens its own system dialog asking the user to +//! pick a key; while the dialog is open the engine continues +//! at L0Focused — the audio path is not blocked. +//! * Once the user accepts, the task subscribes to +//! `Activated`/`Deactivated` signals scoped to the session +//! handle and calls `gate.set(true/false)` accordingly. +//! * `rebind` re-runs `BindShortcuts` on the same session. +//! * `stop` (or Drop) signals the worker; the worker closes +//! the session handle via the `Request::Close` interface +//! and exits. +//! +//! Privacy posture (DEC-027): the portal returns a +//! `trigger_description` string (a translated human label). We +//! never log it; the backend exposes only the coarse +//! `bound_input_class` derived from a fixed mapping of well-known +//! substrings, plus the stable `backend_id = "gnome-wayland-portal"`. +use std::collections::HashMap; use std::env; use std::sync::Arc; +use tokio::sync::{mpsc, watch, Mutex as TokioMutex}; use tracing::{info, warn}; -use zbus::blocking::Connection; -use zbus::proxy; +use zbus::blocking::Connection as BlockingConnection; +use zbus::zvariant::{OwnedValue, Value}; +use zbus::{proxy, Connection as AsyncConnection}; use super::{ - AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, + AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass, }; use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel}; @@ -59,6 +87,14 @@ fn is_gnome_on_wayland() -> bool { desktop.split(':').any(|s| s == "gnome" || s == "gnome-flashback") } +// ---------- D-Bus proxies ---------- +// +// zbus 5 cannot emit blocking + async on the same trait, so we +// declare two parallel traits sharing the same interface +// metadata. `BlockingGlobalShortcuts` is used only by +// `try_select()` for the cheap version probe; `GlobalShortcuts` +// is used by the worker task on the async runtime. + #[proxy( interface = "org.freedesktop.portal.GlobalShortcuts", default_service = "org.freedesktop.portal.Desktop", @@ -66,69 +102,176 @@ fn is_gnome_on_wayland() -> bool { gen_blocking = true, gen_async = false )] -trait GlobalShortcuts { - /// Version property (we probe for the interface by reading it). +trait BlockingGlobalShortcuts { + /// Version property (read on the blocking proxy as a fast + /// reachability probe). #[zbus(property)] fn version(&self) -> zbus::Result; } +#[proxy( + interface = "org.freedesktop.portal.GlobalShortcuts", + default_service = "org.freedesktop.portal.Desktop", + default_path = "/org/freedesktop/portal/desktop", + gen_blocking = false, + gen_async = true +)] +trait GlobalShortcuts { + /// Create a portal session. The returned object path + /// identifies a `Request` whose `Response` signal carries the + /// real session-handle path. + fn create_session( + &self, + options: HashMap<&str, Value<'_>>, + ) -> zbus::Result; + + /// Bind the listed shortcuts on the session. The portal opens + /// its own system dialog asking the user to pick a key. The + /// returned object path identifies a `Request` whose + /// `Response` signal carries the bound shortcuts. + fn bind_shortcuts( + &self, + session_handle: &zbus::zvariant::ObjectPath<'_>, + shortcuts: &[(&str, HashMap<&str, Value<'_>>)], + parent_window: &str, + options: HashMap<&str, Value<'_>>, + ) -> zbus::Result; + + /// Fired when a bound shortcut is pressed. Scoped per session + /// handle. + #[zbus(signal)] + fn activated( + &self, + session_handle: zbus::zvariant::ObjectPath<'_>, + shortcut_id: String, + timestamp: u64, + options: HashMap, + ) -> zbus::Result<()>; + + /// Fired when a bound shortcut is released. + #[zbus(signal)] + fn deactivated( + &self, + session_handle: zbus::zvariant::ObjectPath<'_>, + shortcut_id: String, + timestamp: u64, + options: HashMap, + ) -> zbus::Result<()>; +} + +#[proxy( + interface = "org.freedesktop.portal.Request", + default_service = "org.freedesktop.portal.Desktop", + gen_blocking = false, + gen_async = true +)] +trait Request { + /// The portal completes each long-running call by emitting + /// `Response(u response_code, a{sv} results)`. `response_code` + /// is `0` for success, `1` for user cancellation, `2` for + /// other failure. + #[zbus(signal)] + fn response( + &self, + response: u32, + results: HashMap, + ) -> zbus::Result<()>; + + /// Cancel an in-flight request. + fn close(&self) -> zbus::Result<()>; +} + +/// Session-handle interface — distinct from the per-request +/// `Request` interface above. The portal exposes +/// `org.freedesktop.portal.Session` on the same object path that +/// `CreateSession` returns in its `Response.results.session_handle`. +/// Closing the session releases all bound shortcuts and stops +/// signal delivery. +#[proxy( + interface = "org.freedesktop.portal.Session", + default_service = "org.freedesktop.portal.Desktop", + gen_blocking = false, + gen_async = true +)] +trait Session { + /// Close the session and release its bound shortcuts. + fn close(&self) -> zbus::Result<()>; +} + +/// Stable sentinel id for the single PTT shortcut Chanora binds. +const SHORTCUT_ID: &str = "chanora-ptt"; + +// ---------- Backend ---------- + pub struct LinuxGnomeWaylandBackend { - conn: Arc, - 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, + inner: Arc>, + /// Watch sender; updated by the worker task to publish + /// descriptor transitions (e.g. portal cancellation downgrade). + desc_tx: watch::Sender, + /// Receiver retained so the trait's `descriptor_watch` can + /// return a fresh subscriber without holding the lock. + desc_rx: watch::Receiver, +} + +struct BackendInner { + /// Async command channel for the worker task. `None` before + /// `start()` and after `stop()`. + cmd_tx: Option>, + /// Join handle for the worker; aborted on `stop()` / Drop. + worker: Option>, +} + +enum WorkerCmd { + /// Rebind: re-issue `BindShortcuts` on the same session. + Rebind, + /// Stop: close the session and exit. + Stop, } impl LinuxGnomeWaylandBackend { + /// Synchronous probe via the blocking proxy. Cheap; runs once + /// at `try_select` time before we commit to the live flow. fn probe() -> Result { - // 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() + let conn = BlockingConnection::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}")))?; + let version = { + let proxy = BlockingGlobalShortcutsProxy::new(&conn) + .map_err(|e| PttBackendError::Init(format!("proxy: {e}")))?; + proxy + .version() + .map_err(|e| PttBackendError::Init(format!("portal version: {e}")))? + }; info!( target: "chanora_audio", portal_version = version, "linux ptt: GlobalShortcuts portal v{} reachable", version ); + + let initial = PttBackendDescriptor { + level: PttCapabilityLevel::L0Focused, + backend_id: "gnome-wayland-portal", + bound_input_class: None, + }; + let (desc_tx, desc_rx) = watch::channel(initial); Ok(Self { - conn: Arc::new(conn), - binding: PttBinding::none(), - gate: None, + inner: Arc::new(TokioMutex::new(BackendInner { + cmd_tx: None, + worker: None, + })), + desc_tx, + desc_rx, }) } } -/// 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 { - 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"), - }, - } + self.desc_rx.borrow().clone() + } + + fn descriptor_watch(&self) -> watch::Receiver { + self.desc_rx.clone() } fn start( @@ -136,39 +279,536 @@ impl DesktopPttBackend for LinuxGnomeWaylandBackend { 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)" - ); + // Spawn the async worker that owns the live D-Bus + // connection. `tokio::spawn` requires a runtime in + // context; the audio engine is constructed from inside + // the bridge's tokio runtime, so this is satisfied. + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + let desc_tx = self.desc_tx.clone(); + let class_hint = binding.input_class; + let worker = tokio::spawn(async move { + run_worker(gate, cmd_rx, desc_tx, class_hint).await; + }); + // Stash command + worker handles. `try_lock` is fine: the + // backend isn't yet shared, and `start` is called once at + // engine init. + let mut inner = self.inner.try_lock().map_err(|_| { + PttBackendError::Init("backend inner mutex contended".to_string()) + })?; + // Clean up any prior worker (defensive — `start` is + // expected to be called exactly once per backend + // instance). + if let Some(prev_tx) = inner.cmd_tx.take() { + let _ = prev_tx.send(WorkerCmd::Stop); + } + if let Some(prev_handle) = inner.worker.take() { + prev_handle.abort(); + } + inner.cmd_tx = Some(cmd_tx); + inner.worker = Some(worker); + let _ = &binding; // captured by class_hint Ok(()) } fn stop(&mut self) { - if let Some(g) = self.gate.take() { - g.set(false); + // Signal the worker and abort the join handle. We take + // the mutex synchronously since stop() is sync. + if let Ok(mut inner) = self.inner.try_lock() { + if let Some(tx) = inner.cmd_tx.take() { + let _ = tx.send(WorkerCmd::Stop); + } + if let Some(h) = inner.worker.take() { + h.abort(); + } } + // Reset descriptor to the post-stop state so a future + // engine restart starts from a clean baseline. + let _ = self.desc_tx.send(PttBackendDescriptor { + level: PttCapabilityLevel::L0Focused, + backend_id: "gnome-wayland-portal", + bound_input_class: None, + }); } fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> { - self.binding = binding; + // Honest behaviour: the portal exposes only a fixed + // shortcut list per session; rebinding re-issues + // `BindShortcuts` which causes the portal to prompt the + // user for the new key. The class-hint is bookkeeping + // only — the portal decides the actual binding. + if let Ok(inner) = self.inner.try_lock() { + if let Some(tx) = inner.cmd_tx.as_ref() { + let _ = tx.send(WorkerCmd::Rebind); + return Ok(()); + } + } + // No worker running: bind on next `start`. + let _ = binding; Ok(()) } } -// Keep the connection alive across the backend's lifetime. impl Drop for LinuxGnomeWaylandBackend { fn drop(&mut self) { - // Arc drops here automatically. - let _ = &self.conn; + self.stop(); + } +} + +// ---------- Worker ---------- + +async fn run_worker( + gate: AudioTransmitGate, + mut cmd_rx: mpsc::UnboundedReceiver, + desc_tx: watch::Sender, + initial_class_hint: PttInputClass, +) { + // Open an async D-Bus session connection. If this fails we + // emit a warning and exit; the descriptor stays at L0Focused + // because that's the watch's initial value. + let conn = match AsyncConnection::session().await { + Ok(c) => c, + Err(e) => { + warn!( + target: "chanora_audio", + error = %e, + "linux ptt: async session-bus connection failed; falling back to Focused" + ); + return; + } + }; + let proxy = match GlobalShortcutsProxy::new(&conn).await { + Ok(p) => p, + Err(e) => { + warn!( + target: "chanora_audio", + error = %e, + "linux ptt: GlobalShortcuts proxy creation failed; falling back to Focused" + ); + return; + } + }; + + // Create a session. + let session_handle = match create_session(&proxy, &conn).await { + Ok(h) => h, + Err(e) => { + warn!( + target: "chanora_audio", + error = %e, + "linux ptt: CreateSession failed; falling back to Focused" + ); + return; + } + }; + info!( + target: "chanora_audio", + "linux ptt: portal session created" + ); + + // Bind the initial shortcut. The portal opens its own dialog. + match bind_shortcut(&proxy, &conn, &session_handle).await { + Ok(class) => publish_bound(&desc_tx, class.unwrap_or(initial_class_hint)), + Err(BindError::Cancelled) => { + warn!( + target: "chanora_audio", + bind_status = "cancelled", + "linux ptt: user cancelled BindShortcuts; descriptor stays at L0Focused" + ); + publish_l0(&desc_tx); + } + Err(BindError::Failed(e)) => { + warn!( + target: "chanora_audio", + bind_status = "failed", + error = %e, + "linux ptt: BindShortcuts failed; descriptor stays at L0Focused" + ); + publish_l0(&desc_tx); + } + } + + // Subscribe to Activated / Deactivated signals scoped to the + // session handle, plus pump the command channel. + let mut activated = match proxy.receive_activated().await { + Ok(s) => s, + Err(e) => { + warn!( + target: "chanora_audio", + error = %e, + "linux ptt: receive_activated subscription failed" + ); + return; + } + }; + let mut deactivated = match proxy.receive_deactivated().await { + Ok(s) => s, + Err(e) => { + warn!( + target: "chanora_audio", + error = %e, + "linux ptt: receive_deactivated subscription failed" + ); + return; + } + }; + use futures_util::StreamExt; + loop { + tokio::select! { + cmd = cmd_rx.recv() => { + match cmd { + Some(WorkerCmd::Rebind) => { + match bind_shortcut(&proxy, &conn, &session_handle).await { + Ok(class) => publish_bound(&desc_tx, class.unwrap_or(initial_class_hint)), + Err(BindError::Cancelled) => publish_l0(&desc_tx), + Err(BindError::Failed(_)) => publish_l0(&desc_tx), + } + } + Some(WorkerCmd::Stop) | None => { + // Close the portal session via the + // `org.freedesktop.portal.Session.Close` + // method on the session-handle object + // path. This is distinct from + // `Request::Close` (which would cancel a + // pending Request, not release the + // bound shortcuts). Best-effort: failures + // here are not user-visible — the worker + // is exiting either way, and the portal + // will eventually reap orphaned sessions + // when the calling unique D-Bus name + // disconnects. + if let Ok(builder) = SessionProxy::builder(&conn).path(session_handle.clone()) { + if let Ok(sp) = builder.build().await { + let _ = sp.close().await; + } + } + gate.set(false); + return; + } + } + } + signal = activated.next() => { + if let Some(s) = signal { + if let Ok(args) = s.args() { + if args.session_handle.as_str() == session_handle.as_str() + && args.shortcut_id == SHORTCUT_ID + { + gate.set(true); + } + } + } + } + signal = deactivated.next() => { + if let Some(s) = signal { + if let Ok(args) = s.args() { + if args.session_handle.as_str() == session_handle.as_str() + && args.shortcut_id == SHORTCUT_ID + { + gate.set(false); + } + } + } + } + } + } +} + +async fn create_session( + proxy: &GlobalShortcutsProxy<'_>, + conn: &AsyncConnection, +) -> zbus::Result { + use rand::Rng; + let token: u32 = rand::thread_rng().gen_range(0..u32::MAX); + let handle_token = format!("chanora{}", token); + let session_token = format!("chanora_session_{}", token); + let mut opts = HashMap::new(); + opts.insert("handle_token", Value::from(handle_token.as_str())); + opts.insert("session_handle_token", Value::from(session_token.as_str())); + let request_path = proxy.create_session(opts).await?; + let response = await_response(conn, &request_path).await?; + let session_handle = response + .get("session_handle") + .and_then(|v| <&str>::try_from(v).ok()) + .map(|s| s.to_string()) + .ok_or_else(|| zbus::Error::Failure("CreateSession returned no session_handle".to_string()))?; + Ok(zbus::zvariant::OwnedObjectPath::try_from(session_handle) + .map_err(|e| zbus::Error::Failure(format!("session_handle path parse: {e}")))?) +} + +enum BindError { + Cancelled, + Failed(String), +} + +async fn bind_shortcut( + proxy: &GlobalShortcutsProxy<'_>, + conn: &AsyncConnection, + session_handle: &zbus::zvariant::OwnedObjectPath, +) -> Result, BindError> { + // Shortcut entry: id, options-dict. We pass a description + // ("Push-to-talk") so the portal dialog labels the prompt + // for the user; the actual key is chosen by the user inside + // the dialog. + let mut shortcut_opts: HashMap<&str, Value<'_>> = HashMap::new(); + shortcut_opts.insert("description", Value::from("Chanora push-to-talk")); + let shortcuts: Vec<(&str, HashMap<&str, Value<'_>>)> = vec![(SHORTCUT_ID, shortcut_opts)]; + + let request_path = proxy + .bind_shortcuts(&session_handle.as_ref(), &shortcuts, "", HashMap::new()) + .await + .map_err(|e| BindError::Failed(format!("BindShortcuts call: {e}")))?; + let response = await_response(conn, &request_path) + .await + .map_err(|e| match e { + zbus::Error::Failure(s) if s.contains("cancelled") => BindError::Cancelled, + other => BindError::Failed(format!("BindShortcuts response: {other}")), + })?; + // The portal returns a `shortcuts` array of (id, properties). + // We only inspect the `trigger_description` field, classify + // it heuristically into keyboard or mouse-side-button, and + // discard the raw string (DEC-027). + let class = response + .get("shortcuts") + .and_then(|v| classify_shortcuts_value(v)); + Ok(class) +} + +/// Wait for the `Response` signal on `request_path`. Returns the +/// `results` dict on success (response code 0) or an Error +/// otherwise. +async fn await_response( + conn: &AsyncConnection, + request_path: &zbus::zvariant::OwnedObjectPath, +) -> zbus::Result> { + let req = RequestProxy::builder(conn) + .path(request_path.clone())? + .build() + .await?; + use futures_util::StreamExt; + let mut stream = req.receive_response().await?; + if let Some(signal) = stream.next().await { + let args = signal.args()?; + match args.response { + 0 => Ok(args.results.clone()), + 1 => Err(zbus::Error::Failure( + "portal request cancelled by user".to_string(), + )), + other => Err(zbus::Error::Failure(format!( + "portal request failed with code {other}" + ))), + } + } else { + Err(zbus::Error::Failure( + "portal Response signal channel closed".to_string(), + )) + } +} + +/// Heuristically classify the portal's `shortcuts` reply into a +/// `PttInputClass`. The portal returns an `a(sa{sv})` value; each +/// element is `(shortcut_id, properties)` where `properties` may +/// contain `trigger_description` (a translated human label). +/// +/// Per DEC-027 the raw string is never logged. We look for one +/// of two well-known substrings — `mouse` indicates a side-button +/// binding; anything else is treated as keyboard. +fn classify_shortcuts_value(v: &OwnedValue) -> Option { + let sliced: Vec<(String, HashMap)> = + Vec::<(String, HashMap)>::try_from(v.clone()).ok()?; + for (id, props) in sliced { + if id == SHORTCUT_ID { + let class = props + .get("trigger_description") + .and_then(|d| <&str>::try_from(d).ok()) + .map(|s| { + if s.to_ascii_lowercase().contains("mouse") { + PttInputClass::MouseSideButton + } else { + PttInputClass::Keyboard + } + }) + .unwrap_or(PttInputClass::Keyboard); + return Some(class); + } + } + None +} + +fn publish_bound(tx: &watch::Sender, class: PttInputClass) { + let bound = match class { + PttInputClass::MouseSideButton => Some("mouse-side-button"), + PttInputClass::None => None, + _ => Some("keyboard"), + }; + let level = match class { + PttInputClass::MouseSideButton => PttCapabilityLevel::L3GlobalWithMouseButtons, + _ => PttCapabilityLevel::L2GlobalHoldToTalk, + }; + let _ = tx.send(PttBackendDescriptor { + level, + backend_id: "gnome-wayland-portal", + bound_input_class: bound, + }); +} + +fn publish_l0(tx: &watch::Sender) { + let _ = tx.send(PttBackendDescriptor { + level: PttCapabilityLevel::L0Focused, + backend_id: "gnome-wayland-portal", + bound_input_class: None, + }); +} + +#[cfg(test)] +mod tests { + //! Unit tests for the diagnostics-safe classifier and the + //! descriptor watch publishers. The live portal session + //! flow itself needs a D-Bus session and is covered by the + //! `tests/linux_portal_smoke.rs` ignored integration test. + + use super::*; + use std::collections::HashMap; + use zbus::zvariant::{OwnedValue, Value}; + + fn shortcut_entry(id: &str, trigger: Option<&str>) -> (String, HashMap) { + let mut props: HashMap = HashMap::new(); + if let Some(t) = trigger { + props.insert( + "trigger_description".to_string(), + OwnedValue::try_from(Value::from(t)).expect("trigger value"), + ); + } + (id.to_string(), props) + } + + fn shortcuts_owned_value(entries: Vec<(String, HashMap)>) -> OwnedValue { + // Build the portal's `a(sa{sv})` reply shape. Use + // `Value::from` over the Vec; zvariant emits the array + // signature from the tuple. + let val = Value::from(entries); + OwnedValue::try_from(val).expect("shortcuts owned value") + } + + #[test] + fn classify_returns_none_when_shortcut_id_missing() { + let v = shortcuts_owned_value(vec![shortcut_entry("other-id", Some("Ctrl+Alt+P"))]); + assert_eq!(classify_shortcuts_value(&v), None); + } + + #[test] + fn classify_returns_keyboard_for_typical_trigger_description() { + let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, Some("Ctrl+Alt+P"))]); + assert_eq!( + classify_shortcuts_value(&v), + Some(PttInputClass::Keyboard) + ); + } + + #[test] + fn classify_returns_keyboard_when_trigger_description_missing() { + let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, None)]); + assert_eq!( + classify_shortcuts_value(&v), + Some(PttInputClass::Keyboard) + ); + } + + #[test] + fn classify_detects_mouse_substring() { + let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, Some("Mouse Button 4"))]); + assert_eq!( + classify_shortcuts_value(&v), + Some(PttInputClass::MouseSideButton) + ); + } + + #[test] + fn classify_is_case_insensitive_on_mouse_substring() { + let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, Some("MOUSE5"))]); + assert_eq!( + classify_shortcuts_value(&v), + Some(PttInputClass::MouseSideButton) + ); + } + + #[test] + fn publish_bound_keyboard_publishes_L2_with_keyboard_class() { + let initial = PttBackendDescriptor { + level: PttCapabilityLevel::L0Focused, + backend_id: "gnome-wayland-portal", + bound_input_class: None, + }; + let (tx, mut rx) = watch::channel(initial.clone()); + // Skip the initial value. + rx.borrow_and_update(); + publish_bound(&tx, PttInputClass::Keyboard); + let d = rx.borrow_and_update().clone(); + assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk); + assert_eq!(d.backend_id, "gnome-wayland-portal"); + assert_eq!(d.bound_input_class, Some("keyboard")); + } + + #[test] + fn publish_bound_mouse_publishes_L3() { + let initial = PttBackendDescriptor { + level: PttCapabilityLevel::L0Focused, + backend_id: "gnome-wayland-portal", + bound_input_class: None, + }; + let (tx, mut rx) = watch::channel(initial); + rx.borrow_and_update(); + publish_bound(&tx, PttInputClass::MouseSideButton); + let d = rx.borrow_and_update().clone(); + assert_eq!(d.level, PttCapabilityLevel::L3GlobalWithMouseButtons); + assert_eq!(d.bound_input_class, Some("mouse-side-button")); + } + + #[test] + fn publish_bound_none_publishes_L2_keyboard_default() { + // `PttInputClass::None` falls through the `_ => Keyboard` + // arm in `publish_bound`. The descriptor level is the + // keyboard default; bound_input_class is `Some("keyboard")` + // because the descriptor's bound match treats anything + // other than `MouseSideButton` / `None` as keyboard, and + // `PttInputClass::None` ends up at the keyboard arm of + // `bound` via the `_` arm before reaching `None`. + // We document the actual mapping here so a future change + // of the match arm order is caught. + let initial = PttBackendDescriptor::focused(); + let (tx, mut rx) = watch::channel(initial); + rx.borrow_and_update(); + publish_bound(&tx, PttInputClass::None); + let d = rx.borrow_and_update().clone(); + // The `bound` match's `PttInputClass::None => None` arm + // is selected. Level still resolves to L2 because the + // outer match's `_ => L2GlobalHoldToTalk` covers the + // `None` case. + assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk); + assert_eq!(d.bound_input_class, None); + } + + #[test] + fn publish_l0_clears_descriptor() { + // Seed with a Global descriptor so we can observe the + // downgrade. + let initial = PttBackendDescriptor { + level: PttCapabilityLevel::L2GlobalHoldToTalk, + backend_id: "gnome-wayland-portal", + bound_input_class: Some("keyboard"), + }; + let (tx, mut rx) = watch::channel(initial); + rx.borrow_and_update(); + publish_l0(&tx); + let d = rx.borrow_and_update().clone(); + assert_eq!(d.level, PttCapabilityLevel::L0Focused); + assert_eq!(d.backend_id, "gnome-wayland-portal"); + assert_eq!(d.bound_input_class, None); + } + + #[test] + fn shortcut_id_is_stable() { + // The shortcut id is shipped to the portal and persists + // across portal restarts; flipping it would silently + // orphan any pre-existing binding. + assert_eq!(SHORTCUT_ID, "chanora-ptt"); } } diff --git a/crates/chanora_audio/src/ptt_backends/mod.rs b/crates/chanora_audio/src/ptt_backends/mod.rs index 7c2e171..6702318 100644 --- a/crates/chanora_audio/src/ptt_backends/mod.rs +++ b/crates/chanora_audio/src/ptt_backends/mod.rs @@ -144,6 +144,27 @@ pub trait DesktopPttBackend: Send { /// Privacy-safe descriptor of this backend instance. fn descriptor(&self) -> PttBackendDescriptor; + /// Subscribe to descriptor transitions. Implementations that + /// cannot transition asynchronously (`FocusedPttBackend`, + /// `WindowsRawInputBackend`, `WindowsHookBackend`, + /// `MacOSEventTapBackend` today) return a receiver that + /// observes only the initial value and never fires again. The + /// Linux portal backend uses this channel to publish the + /// post-`BindShortcuts` capability transition (Bound / + /// Cancelled). + fn descriptor_watch(&self) -> tokio::sync::watch::Receiver { + let (_tx, rx) = tokio::sync::watch::channel(self.descriptor()); + // Hold `_tx` alive by leaking it — receivers detect the + // sender drop via `changed()` returning `Err`, which the + // core supervisor treats as "no further updates". By + // leaking we instead keep the receiver "live" indefinitely + // (no spurious closed-channel events), and the channel is + // dropped together with the audio engine because the + // backend Drop releases the box. + std::mem::forget(_tx); + rx + } + /// Arm the backend. After this call the backend listens for /// the bound input and toggles the gate accordingly. fn start( diff --git a/crates/chanora_audio/tests/linux_portal_smoke.rs b/crates/chanora_audio/tests/linux_portal_smoke.rs new file mode 100644 index 0000000..daffaeb --- /dev/null +++ b/crates/chanora_audio/tests/linux_portal_smoke.rs @@ -0,0 +1,42 @@ +//! Linux GNOME-Wayland portal smoke test (RR-PTT-004). +//! +//! `cargo test -p chanora_audio --test linux_portal_smoke -- --ignored` +//! runs the cheap probe path against the active D-Bus session and +//! asserts the `org.freedesktop.portal.GlobalShortcuts` interface +//! is reachable. Marked `#[ignore]` because: +//! +//! * It needs a live D-Bus session (CI runners typically don't +//! have one, and our existing live tests are similarly gated). +//! * It needs `xdg-desktop-portal-gnome` (or another backend +//! that exposes `GlobalShortcuts`) to actually own the well- +//! known service. +//! +//! Run from inside a GNOME-on-Wayland session. + +#![cfg(target_os = "linux")] + +#[test] +#[ignore = "live; needs xdg-desktop-portal with GlobalShortcuts on the user session bus"] +fn portal_global_shortcuts_reachable() { + // We don't pull chanora_audio's private modules in directly; + // instead we replicate the cheap version-probe call so we + // exercise the *exact* path try_select uses. The crate's + // `try_select` is the integration boundary the audio engine + // depends on, so a follow-up test could be added that + // constructs a `LinuxGnomeWaylandBackend` through the public + // factory and asserts its descriptor. + use zbus::blocking::{Connection, Proxy}; + let conn = Connection::session().expect("session bus"); + let proxy = Proxy::new( + &conn, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.GlobalShortcuts", + ) + .expect("proxy"); + let version: u32 = proxy + .get_property("version") + .expect("GlobalShortcuts.version property; portal not exposing the interface?"); + assert!(version >= 1, "portal version {version} unexpected"); + eprintln!("GlobalShortcuts portal reachable, version = {version}"); +} diff --git a/docs/architecture/desktop-ptt-architecture.md b/docs/architecture/desktop-ptt-architecture.md index 64b48cc..3040eaa 100644 --- a/docs/architecture/desktop-ptt-architecture.md +++ b/docs/architecture/desktop-ptt-architecture.md @@ -115,12 +115,30 @@ The audio engine starts immediately on user request; the permission state is que ### 5.3 Linux -Two-level ladder restricted to the officially-tested environment per DEC-025: +Two-level ladder restricted to the officially-tested environment per DEC-025. -1. **`LinuxGnomeWaylandBackend`** — used when `XDG_SESSION_TYPE=wayland` and the desktop environment is GNOME, **and** the `org.freedesktop.portal.GlobalShortcuts` D-Bus interface is reachable. Calls `CreateSession`, `BindShortcuts` (delegates binding capture to the portal's own dialog), and listens for `Activated` / `Deactivated` signals. Reports `L2GlobalHoldToTalk`; mouse-button support follows whatever the portal exposes for the current session. -2. **`FocusedPttBackend`** — fallback on any other Linux environment (X11, sway, KDE, untested compositor, missing portal). Reports `L0Focused`. +**Selection** at `try_select()` time: -Per DEC-025 the application does **not** claim Global PTT support on an untested Linux environment. The UI capability badge explicitly notes "Focused PTT — untested compositor for Global PTT" when the user runs Chanora outside GNOME-on-Wayland. +* Reads `XDG_SESSION_TYPE` (must equal `wayland`) and `XDG_CURRENT_DESKTOP` (must contain `gnome` or `gnome-flashback`). +* Probes `org.freedesktop.portal.GlobalShortcuts` via a blocking `version` property read. + +If either check fails the factory returns `None` and the caller falls back to `FocusedPttBackend` at `L0Focused`. + +**Live session lifecycle (Q5b — lazy, single backend instance):** + +1. `start(gate, binding)` spawns a worker `tokio::spawn` task. The worker owns its own async `zbus::Connection` and a `GlobalShortcutsProxy`. +2. The worker calls `CreateSession` with fresh `handle_token` / `session_handle_token` values (random `u32` per process). The portal returns a `Request` object path; the worker subscribes to the `Response` signal on that path and awaits the session handle from the `results` dict. +3. The worker then calls `BindShortcuts(session_handle, [("chanora-ptt", { description: "Chanora push-to-talk" })], "", {})`. The portal opens its own system-managed dialog asking the user to choose a key — Chanora does not read raw key events. The audio engine continues at `L0Focused` while the dialog is open (the descriptor watch publishes the transition once the portal returns). +4. Once `BindShortcuts` resolves: + * On `response_code == 0`: classify the `trigger_description` substring heuristically (`mouse` → `mouse-side-button`; otherwise `keyboard`), publish `descriptor() = L2GlobalHoldToTalk` (or `L3GlobalWithMouseButtons` for the mouse case) through the watch sender. The trigger_description string itself is never logged. + * On `response_code == 1` (user cancelled) or `> 1` (other failure): publish `descriptor() = L0Focused` through the watch sender. The user can retry via the UI "Configure" button. +5. The worker then enters its long-lived loop, multiplexing on the command channel (`Rebind` / `Stop`) and the portal's `Activated` / `Deactivated` signals. Signal payloads scoped to a different session handle or shortcut id are ignored. Matching `Activated` calls `gate.set(true)`; matching `Deactivated` calls `gate.set(false)`. +6. `rebind(binding)` sends a command to the worker which re-runs `BindShortcuts` on the same session. The portal opens its dialog again; the user can pick a new key. +7. `stop()` (or backend Drop) signals the worker, which calls `Request::Close` on the session handle, calls `gate.set(false)`, and exits. + +**UX implication on Linux:** the in-app `_PttBindingCaptureDialog` (used by Windows and macOS in MVP) is **skipped** when the active backend is `gnome-wayland-portal`. The Flutter "Configure" button calls `setPttBinding` directly with a sentinel `platform_key = "portal"` and shows a SnackBar telling the user their desktop will open its own shortcut dialog. This matches the portal's design (DEC-027 / Q3a). + +**Per DEC-025 the application does not claim Global PTT support on an untested Linux environment.** The UI capability badge explicitly notes "Focused PTT — untested compositor for Global PTT" when the user runs Chanora outside GNOME-on-Wayland. ## 6. Privacy Rule @@ -178,3 +196,4 @@ SysDes-142..148 -> SYS4-SIV-016 | Version | Date | Description | |---|---|---| | 0.9.3 | 2026-05-15 | Initial baseline-candidate architecture for capability-based desktop Push-to-Talk. Codifies the owner rulings for PTT-OPEN-001 through PTT-OPEN-006 as DEC-023 through DEC-028. | +| 0.9.4 | 2026-05-15 | Promoted the Linux GNOME-Wayland backend from probe-only to the live `CreateSession` + `BindShortcuts` + `Activated` / `Deactivated` session flow. Owns an async tokio task with a dedicated `zbus::Connection`; publishes descriptor transitions through a `watch::Sender` consumed by `chanora_core::ChanoraSession::start_audio`. Adds the privacy-safe `trigger_description` classifier and the cancellation / failure path (downgrade to `L0Focused` + re-emit). Updates the Flutter UI to skip the in-app capture dialog on the portal backend (Q3a) and surface a SnackBar redirecting the user to the desktop's own dialog. | diff --git a/docs/governance/product-decision-register.md b/docs/governance/product-decision-register.md index 1dbd7b1..d6f0fb3 100644 --- a/docs/governance/product-decision-register.md +++ b/docs/governance/product-decision-register.md @@ -210,3 +210,4 @@ release but is not an open decision: | 0.9.7 | 2026-05-14 | DEC-001 release-sequence progress recorded: Internal Alpha (`v0.1.0-alpha.1`, commit 3bb038c) completed on 2026-05-14; **Internal Beta first build (`v0.2.0-beta.1`)** reached the same day. Beta milestone adds voice in/out: `crates/chanora_audio` promoted from scaffold to a cpal-based capture + playback engine with `audiopus` Opus encoding and tsclientlib `AudioHandler` for decode + jitter buffer + mix; `crates/chanora_protocol` extended to forward inbound voice packets and accept outbound `OutPacket`s via mpsc channels; `core/chanora_core::ChanoraSession` exposes `start_audio`, `set_ptt`, and `audio_stats`; `crates/chanora_bridge` adds matching DTOs; the Flutter UI gains a "Start audio" action and a hold-to-talk PTT button with live frame counters. Verified end-to-end against `cn.teamspeak.app`; capture runs in graceful playback-only mode on hosts with no usable microphone (e.g. the PipeWire `auto_null` source on the verification host). No decision rows change; this entry documents progress against DEC-001 only. | | 0.9.8 | 2026-05-15 | DEC-001 release-sequence progress recorded for the polished Internal Beta and the External Beta milestones, plus the first MVP-public release candidate. **`v0.3.0-beta.1`** ("Internal Beta polish") added the supervisor + reconnect-with-watchdog path (A.6), OS-connectivity-aware backoff (A.6.1), persistent identity at rest as a plain 0600 file (A.2), the redacted in-memory log sink + user-initiated diagnostic export per DEC-016 (A.3), the `SnapshotChanged` lifecycle event for UI auto-refresh (A.4), and the `mobile_voice_preset` config-surface plumb-through (A.5). **`v0.4.0-beta.2`** ("External Beta") added the server-password input, channel join via tap, self mute (input + output), master output gain, SQLite-backed bookmark list, ChaCha20-Poly1305 encryption of the identity at rest with the DEK in a separate `identity.dek` file, Android `AudioManager.setMode(MODE_IN_COMMUNICATION)` routing engagement via JNI, and the `.github/workflows/ci.yml` pipeline. **`v1.0.0-rc.1`** ("MVP Public release candidate") closes the v0.4 DEK-on-disk weakness on every keyring-reachable platform: `chanora_storage::IdentityFileStore` now stores the DEK in the OS keyring (Linux Secret Service via D-Bus / macOS Keychain / Windows Credential Manager / iOS Keychain via the `keyring` crate) and migrates pre-existing file-fallback installs into the keyring opportunistically; bookmark server passwords are ChaCha20-Poly1305-encrypted under the same per-install DEK and the legacy plain `password TEXT` column is upgraded into a new `password_blob BLOB` column on the next `update()`; `SessionEvent::SnapshotChanged` now fires on any tree mutation (the in-channel-move blind spot from A.4 is closed); the in-app About dialog surfaces DEC-018 / DEC-019 / DEC-020. New `docs/governance/legal-review-readiness.md` carries the DEC-012 handoff package (trademark check, non-affiliation wording, third-party license posture, `cargo about` deliverables, `cargo deny` lifelines); new `docs/governance/staged-release-plan.md` enumerates the DEC-002 platform staging (Linux + Android sideload GA on DEC-012 sign-off; Windows, macOS, iOS gate on per-platform signed-build availability). No decision rows change; DEC-012 remains the sole outstanding release gate. | | 0.9.9 | 2026-05-15 | Recorded six new accepted decisions DEC-023 through DEC-028 closing the gen2 desktop-PTT review's open questions PTT-OPEN-001 through PTT-OPEN-006: Windows Global PTT is P0/MVP (DEC-023), macOS Global PTT is P0/MVP with permission UX (DEC-024), the officially-tested Linux environment is GNOME-on-Wayland only (DEC-025), mouse side buttons are supported on Windows + macOS and Linux follows the portal (DEC-026), PTT diagnostics carry capability/availability only with no raw key codes (DEC-027), and the missed-key-up watchdog is a P0 release-gate requirement (DEC-028). No prior decision rows are mutated. | +| 0.9.10 | 2026-05-15 | Code-side promotion: the Linux GNOME-Wayland backend (DEC-025) is now a live `org.freedesktop.portal.GlobalShortcuts` session — `CreateSession` + `BindShortcuts` + `Activated` / `Deactivated` signal subscription scoped to the session handle, owned by a dedicated tokio task per backend instance. The Flutter "Configure" button on Linux portal delegates to the portal's own system dialog (Q3a) rather than the in-app `_PttBindingCaptureDialog`. Descriptor transitions broadcast via a `watch::Sender` consumed by `chanora_core::ChanoraSession::start_audio` and forwarded to `SessionEvent::PttCapability`. Cancellation / failure path downgrades to `L0Focused` and re-emits. No decision rows mutate. | diff --git a/docs/release/release-readiness-go-nogo-record.md b/docs/release/release-readiness-go-nogo-record.md index 7da94fc..1d83a79 100644 --- a/docs/release/release-readiness-go-nogo-record.md +++ b/docs/release/release-readiness-go-nogo-record.md @@ -219,7 +219,7 @@ The release readiness checklist for every desktop release artefact gains the fol | RR-PTT-001 Windows Global PTT verified on a Windows reference host. | Windows Platform Owner | Live measurement of `PttCapabilityLevel` + `backend_id` returned at runtime. Backend identifier shall be `raw-input` (preferred) or `low-level-hook` (fallback) for Global. | Open | | RR-PTT-002 macOS Global PTT verified with permission granted on a macOS reference host. | macOS Platform Owner | Live measurement + `permission_state = Granted` reported through the Event-Tap backend; UI capability badge screenshot. | Open | | RR-PTT-003 macOS Focused PTT fallback verified with permission denied. | macOS Platform Owner | Live measurement of `PttCapabilityLevel::L0Focused` after revoking Input Monitoring; UI capability badge screenshot showing the fallback notice. | Open | -| RR-PTT-004 Linux Global PTT verified on GNOME-on-Wayland. | Linux Platform Owner | Live measurement returning `gnome-wayland-portal` backend identifier from a live GNOME-on-Wayland host; portal binding dialog screenshot. | Open | +| RR-PTT-004 Linux Global PTT verified on GNOME-on-Wayland. | Linux Platform Owner | Live measurement returning `gnome-wayland-portal` backend identifier from a live GNOME-on-Wayland host; portal binding dialog screenshot. | **Implemented** (live `CreateSession` + `BindShortcuts` + signal subscription landed in code; awaiting live trace from a GNOME-on-Wayland reference host before the cell can be marked Done). | | RR-PTT-005 Linux Focused fallback verified on a non-tested compositor (any of: X11, sway, KDE) | Linux Platform Owner | Live measurement of `L0Focused` on at least one non-tested compositor; release notes do not claim Global support on the untested environment. | Open | | RR-PTT-006 Diagnostic export carries no key data. | Privacy Reviewer | Inspection of a user-initiated diagnostic export captured while PTT is bound to a real key; export shall contain `capability_level`, `backend_id`, `bound_input_class` and shall not contain a recognisable key code. | Open | | RR-PTT-007 Missed-key-up watchdog timeout demonstrated. | Audio Owner | Test trace showing `transmit_active` clearing after the configured 30 s ceiling when the watchdog forces a release. | **Done (v1.0.0-rc.4)** — covered by `chanora_audio::ptt::tests::watchdog_clears_transmit_after_timeout` (and the negative `watchdog_does_not_clear_on_normal_release`). Live platform trace still required per RR-PTT-001..005. | @@ -231,3 +231,4 @@ A release decision shall be **No-Go** for any platform whose RR-PTT items are no |---|---|---| | 0.9.3 | 2026-05-15 | Added desktop PTT release-readiness items RR-PTT-001 through RR-PTT-008 covering Windows / macOS / Linux Global verification, permission-denied fallback verification, diagnostic-export privacy inspection, missed-key-up watchdog test, and capability-badge UI verification. | | 0.9.4 | 2026-05-15 | RR-PTT-007 (missed-key-up watchdog) flipped to Done — the cross-platform `chanora_audio::ptt::MissedKeyUpWatchdog` ships in v1.0.0-rc.4 with two passing unit tests. Live per-platform traces (RR-PTT-001..005, RR-PTT-008) remain required for the live verification phase but are no longer blocked on engineering. | +| 0.9.5 | 2026-05-15 | RR-PTT-004 status flipped to Implemented (live trace from a GNOME-on-Wayland reference host pending). The Linux backend now runs the full portal `CreateSession` + `BindShortcuts` + `Activated` / `Deactivated` flow on a dedicated tokio task per backend instance. |