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