feat(ptt): live Linux GNOME-Wayland portal session flow (DEC-025)
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`.
This commit is contained in:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"pttConfigureCaptured": "已捕获",
|
||||
"pttConfigurePrivacyNote": "Chanora 不会记录具体的按键值。本对话框只会向应用提交输入类别(键盘 / 鼠标侧键)以及一个跨平台标签。",
|
||||
"pttConfigureSaveAction": "保存",
|
||||
"pttConfigurePortalRedirect": "您的桌面环境将打开自带的快捷键对话框,请在其中选择用于对讲的按键。",
|
||||
"inputMuteAction": "静音麦克风",
|
||||
"inputUnmuteAction": "取消麦克风静音",
|
||||
"outputMuteAction": "静音扬声器",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -112,6 +112,9 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get pttConfigureSaveAction => '保存';
|
||||
|
||||
@override
|
||||
String get pttConfigurePortalRedirect => '您的桌面环境将打开自带的快捷键对话框,请在其中选择用于对讲的按键。';
|
||||
|
||||
@override
|
||||
String get inputMuteAction => '静音麦克风';
|
||||
|
||||
|
||||
@@ -392,13 +392,38 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
}
|
||||
|
||||
Future<void> _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(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<tokio::sync::watch::Receiver<crate::ptt::PttBackendDescriptor>> {
|
||||
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
|
||||
|
||||
@@ -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<u32>;
|
||||
}
|
||||
|
||||
#[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<zbus::zvariant::OwnedObjectPath>;
|
||||
|
||||
/// 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<zbus::zvariant::OwnedObjectPath>;
|
||||
|
||||
/// 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<String, OwnedValue>,
|
||||
) -> 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<String, OwnedValue>,
|
||||
) -> 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<String, OwnedValue>,
|
||||
) -> 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<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>,
|
||||
inner: Arc<TokioMutex<BackendInner>>,
|
||||
/// Watch sender; updated by the worker task to publish
|
||||
/// descriptor transitions (e.g. portal cancellation downgrade).
|
||||
desc_tx: watch::Sender<PttBackendDescriptor>,
|
||||
/// Receiver retained so the trait's `descriptor_watch` can
|
||||
/// return a fresh subscriber without holding the lock.
|
||||
desc_rx: watch::Receiver<PttBackendDescriptor>,
|
||||
}
|
||||
|
||||
struct BackendInner {
|
||||
/// Async command channel for the worker task. `None` before
|
||||
/// `start()` and after `stop()`.
|
||||
cmd_tx: Option<mpsc::UnboundedSender<WorkerCmd>>,
|
||||
/// Join handle for the worker; aborted on `stop()` / Drop.
|
||||
worker: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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<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()
|
||||
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<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"),
|
||||
},
|
||||
}
|
||||
self.desc_rx.borrow().clone()
|
||||
}
|
||||
|
||||
fn descriptor_watch(&self) -> watch::Receiver<PttBackendDescriptor> {
|
||||
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<Connection> drops here automatically.
|
||||
let _ = &self.conn;
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Worker ----------
|
||||
|
||||
async fn run_worker(
|
||||
gate: AudioTransmitGate,
|
||||
mut cmd_rx: mpsc::UnboundedReceiver<WorkerCmd>,
|
||||
desc_tx: watch::Sender<PttBackendDescriptor>,
|
||||
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<zbus::zvariant::OwnedObjectPath> {
|
||||
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<Option<PttInputClass>, 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<HashMap<String, OwnedValue>> {
|
||||
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<PttInputClass> {
|
||||
let sliced: Vec<(String, HashMap<String, OwnedValue>)> =
|
||||
Vec::<(String, HashMap<String, OwnedValue>)>::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<PttBackendDescriptor>, 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<PttBackendDescriptor>) {
|
||||
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<String, OwnedValue>) {
|
||||
let mut props: HashMap<String, OwnedValue> = 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<String, OwnedValue>)>) -> 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PttBackendDescriptor> {
|
||||
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(
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user