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