823 lines
30 KiB
Rust
823 lines
30 KiB
Rust
//! 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
|
|
//! `org.freedesktop.portal.GlobalShortcuts` D-Bus interface: the
|
|
//! portal hosts the binding-capture dialog, so Chanora itself
|
|
//! never reads raw key events. The portal sends `Activated` /
|
|
//! `Deactivated` signals that drive the `AudioTransmitGate`.
|
|
//!
|
|
//! 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 as BlockingConnection;
|
|
use zbus::zvariant::{OwnedValue, Value};
|
|
use zbus::{proxy, Connection as AsyncConnection};
|
|
|
|
use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass};
|
|
use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel};
|
|
|
|
/// Try to construct a `LinuxGnomeWaylandBackend`. Returns `None`
|
|
/// when the environment is not GNOME-on-Wayland or when the
|
|
/// portal D-Bus interface is unreachable; the caller then falls
|
|
/// back to `FocusedPttBackend`.
|
|
pub fn try_select() -> Option<Box<dyn DesktopPttBackend>> {
|
|
if !is_gnome_on_wayland() {
|
|
info!(
|
|
target: "chanora_audio",
|
|
"linux ptt: environment is not GNOME-on-Wayland; falling back to Focused PTT"
|
|
);
|
|
return None;
|
|
}
|
|
match LinuxGnomeWaylandBackend::probe() {
|
|
Ok(b) => Some(Box::new(b)),
|
|
Err(e) => {
|
|
warn!(
|
|
target: "chanora_audio",
|
|
error = %e,
|
|
"linux ptt: portal probe failed; falling back to Focused PTT"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_gnome_on_wayland() -> bool {
|
|
let session_type = env::var("XDG_SESSION_TYPE").unwrap_or_default();
|
|
if session_type != "wayland" {
|
|
return false;
|
|
}
|
|
let desktop = env::var("XDG_CURRENT_DESKTOP")
|
|
.unwrap_or_default()
|
|
.to_ascii_lowercase();
|
|
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",
|
|
default_path = "/org/freedesktop/portal/desktop",
|
|
gen_blocking = true,
|
|
gen_async = false
|
|
)]
|
|
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 {
|
|
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.
|
|
///
|
|
/// `zbus::blocking` internally spins up its own current-thread
|
|
/// tokio runtime to drive the async D-Bus client. If we called
|
|
/// it directly here we would panic with "Cannot start a runtime
|
|
/// from within a runtime" because `try_select()` is invoked
|
|
/// from `PttController::new`, which is called from the audio
|
|
/// engine start path, which runs on the bridge's tokio
|
|
/// multi-thread runtime. We therefore push the blocking probe
|
|
/// onto a fresh OS thread (no ambient runtime), join it
|
|
/// synchronously, and propagate the result. The probe is in
|
|
/// the millisecond range so the join cost is negligible.
|
|
fn probe() -> Result<Self, PttBackendError> {
|
|
let join_result = std::thread::Builder::new()
|
|
.name("chanora-ptt-portal-probe".to_string())
|
|
.spawn(|| -> Result<u32, String> {
|
|
let conn =
|
|
BlockingConnection::session().map_err(|e| format!("session bus: {e}"))?;
|
|
let proxy =
|
|
BlockingGlobalShortcutsProxy::new(&conn).map_err(|e| format!("proxy: {e}"))?;
|
|
proxy.version().map_err(|e| format!("portal version: {e}"))
|
|
})
|
|
.map_err(|e| PttBackendError::Init(format!("probe thread spawn: {e}")))?
|
|
.join()
|
|
.map_err(|_| PttBackendError::Init("probe thread panicked".to_string()))?;
|
|
let version = join_result.map_err(PttBackendError::Init)?;
|
|
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 {
|
|
inner: Arc::new(TokioMutex::new(BackendInner {
|
|
cmd_tx: None,
|
|
worker: None,
|
|
})),
|
|
desc_tx,
|
|
desc_rx,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl DesktopPttBackend for LinuxGnomeWaylandBackend {
|
|
fn descriptor(&self) -> PttBackendDescriptor {
|
|
self.desc_rx.borrow().clone()
|
|
}
|
|
|
|
fn descriptor_watch(&self) -> watch::Receiver<PttBackendDescriptor> {
|
|
self.desc_rx.clone()
|
|
}
|
|
|
|
fn start(
|
|
&mut self,
|
|
gate: AudioTransmitGate,
|
|
binding: PttBinding,
|
|
) -> Result<(), PttBackendError> {
|
|
// 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) {
|
|
// 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> {
|
|
// 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(())
|
|
}
|
|
}
|
|
|
|
impl Drop for LinuxGnomeWaylandBackend {
|
|
fn drop(&mut self) {
|
|
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");
|
|
}
|
|
}
|