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:
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user