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:
EdisonJwa
2026-05-15 16:43:45 +08:00
parent 5199e3d005
commit 82d012a46b
16 changed files with 897 additions and 87 deletions
Generated
+2
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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 => '静音麦克风';
+30 -5
View File
@@ -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(),
+31 -9
View File
@@ -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(())
}
+13 -4
View File
@@ -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"
+13
View File
@@ -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
+704 -64
View File
@@ -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}");
}
+23 -4
View File
@@ -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. |