//! 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> { 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; } #[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; /// 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; /// 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, ) -> 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, ) -> 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) -> 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>, /// Watch sender; updated by the worker task to publish /// descriptor transitions (e.g. portal cancellation downgrade). desc_tx: watch::Sender, /// Receiver retained so the trait's `descriptor_watch` can /// return a fresh subscriber without holding the lock. desc_rx: watch::Receiver, } struct BackendInner { /// Async command channel for the worker task. `None` before /// `start()` and after `stop()`. cmd_tx: Option>, /// Join handle for the worker; aborted on `stop()` / Drop. worker: Option>, } 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 { let join_result = std::thread::Builder::new() .name("chanora-ptt-portal-probe".to_string()) .spawn(|| -> Result { 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 { 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, desc_tx: watch::Sender, 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 { 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, 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> { 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 { let sliced: Vec<(String, HashMap)> = Vec::<(String, HashMap)>::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, 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) { 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) { let mut props: HashMap = 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)>) -> 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"); } }