GlobalShortcuts portal backend
git clone https://git.lucas.co/cce-shortcuts-portal.git
Add the GlobalShortcuts portal backend for cce
A native Wayland app cannot grab a key; it asks xdg-desktop-portal's
GlobalShortcuts interface for one (1Password's Quick Access does), and the
frontend hands that to a backend. This is that backend: BindShortcuts turns
each preferred_trigger into 'shortcut bind' on the compositor's control
socket, and the status socket's 'shortcuts' topic comes back out as the
Activated/Deactivated signals. It does no key handling of its own.
Ships its bus activation file (dbus/) and portal declaration (portals/);
~/.config/xdg-desktop-portal/cce-portals.conf must name it for the
interface. The compositor half is src/server/global_shortcuts.rs in
cce-compositor.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
.gitignore | 1 +
CLAUDE.md | 107 ++++++
Cargo.toml | 13 +
Makefile | 16 +
...sktop.impl.portal.desktop.cce-shortcuts.service | 8 +
portals/cce-shortcuts.portal | 4 +
src/main.rs | 391 +++++++++++++++++++++
7 files changed, 540 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..d94c5f1
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,107 @@
+# CLAUDE.md
+
+Read `../cce-compositor/WORKSPACE.md` first: this crate is one member of the
+cce workspace and follows its multi-repo, `ccebuild` and concurrent-session
+rules.
+
+## What this is
+
+`cce-shortcuts-portal` is the **`org.freedesktop.impl.portal.GlobalShortcuts`
+backend** for the cce compositor — the piece that lets a native Wayland app
+own a global hotkey. A Wayland client cannot grab keys; what it can do is ask
+xdg-desktop-portal to bind a trigger on its behalf (1Password's Quick Access,
+Ctrl+Shift+Space, is the motivating case: its binary calls this portal and
+nothing else). The portal *frontend* forwards that request to whichever
+backend the desktop's portals.conf names for the interface, and until this
+crate existed nothing on the machine declared it, so the frontend did not even
+publish `GlobalShortcuts` on the bus and every app's request failed silently.
+
+The backend is deliberately thin: it does no key handling. Both directions go
+through the compositor's existing sockets, and `src/server/global_shortcuts.rs`
+in cce-compositor is the other half of the contract:
+
+- **Binding** — `BindShortcuts` turns each shortcut's `preferred_trigger`
+ (shortcuts-spec syntax, `CTRL+SHIFT+space`) into `shortcut bind <session>
+ <id> <trigger>` on the control socket. The compositor parses the trigger,
+ refuses a chord its own keybinds already use (the user's config wins, and
+ the app is told it did not get the shortcut rather than having it silently
+ shadowed), and answers with the `trigger_description` the app renders
+ (`Ctrl+Shift+Space`). A shortcut without a `preferred_trigger` is not bound:
+ there is no configuration dialog (interface version 1, no
+ `ConfigureShortcuts`).
+- **Firing** — the backend subscribes to the status socket's `shortcuts`
+ topic and re-emits each `activated|deactivated <session> <id> <time_msec>`
+ line as the portal's `Activated` / `Deactivated` signal. Both edges are
+ reported; neither reaches the focused client.
+- **Sessions** — one `org.freedesktop.impl.portal.Session` object per
+ session at the path the frontend chose. `Close` unbinds that session's
+ chords. Request objects are not exported: every call answers at once, so
+ there is never a pending request to cancel.
+
+Shortcut ids are app-chosen and may contain spaces; the control socket
+splits on whitespace, so ids are percent-encoded on the wire and the
+compositor stores them encoded (`ccectl shortcut list` shows
+`Quick%20Access`). Only this side decodes. `+` is left alone so triggers
+pass through as written.
+
+## Lifecycle
+
+Bus-activated: `dbus/org.freedesktop.impl.portal.desktop.cce-shortcuts.service`
+starts it on the first request. It begins with `shortcut clear`, which both
+drops chords a dead predecessor left bound (nobody would ever hear them
+fire) and proves the compositor speaks the command — an older compositor
+answers `error: unknown command`, and the backend exits rather than claim
+to serve an interface it cannot. It then lives as long as the status socket
+does and exits when the compositor closes it; the next request starts a
+fresh one. Sessions do not survive that, which is right: they were bound in
+a compositor that is gone.
+
+Reads `WAYLAND_DISPLAY` for both socket paths exactly as `ccectl` does, so
+it needs the bus activation environment to carry it (startcce imports it).
+Pointed at a `cce-shadow` instance's environment it drives that shadow
+instead — which is how it is verified.
+
+## Wiring (three files, one of them not installed from here)
+
+- `portals/cce-shortcuts.portal` — declares the bus name and interface;
+ `ccebuild install` puts it in `$XDG_DATA_HOME/xdg-desktop-portal/portals/`
+ (the `portal_files()` step, added for this crate).
+- `dbus/…cce-shortcuts.service` — bus activation, installed to
+ `~/.local/share/dbus-1/services/`. `Exec` is absolute because D-Bus
+ expands nothing, the same as the file chooser's service file.
+- **`~/.config/xdg-desktop-portal/cce-portals.conf`** must name it:
+ `org.freedesktop.impl.portal.GlobalShortcuts=cce-shortcuts`. That file is
+ user config, not versioned anywhere; without the line the frontend's
+ `default=gtk` applies and gtk does not implement the interface.
+
+xdg-desktop-portal reads portal files and the conf at startup:
+`systemctl --user restart xdg-desktop-portal` after installing, then
+`busctl --user introspect org.freedesktop.portal.Desktop
+/org/freedesktop/portal/desktop | grep GlobalShortcuts` shows whether the
+frontend now publishes it. An app that registered its shortcut before that
+(1Password at login) has to be restarted to ask again.
+
+## Verifying without the live session
+
+The frontend is one per session bus, so the full path (app → frontend →
+backend) can only be exercised live. Everything below the frontend can be
+driven in a shadow, and that is where the two bugs so far were found
+(`+` being percent-encoded out of the trigger; a lone modifier accepted as
+a key):
+
+```sh
+cce-shadow start --new # prints agent-N
+cce-shadow --instance agent-N run target/release/cce-shortcuts-portal &
+busctl --user call org.freedesktop.impl.portal.desktop.cce-shortcuts \
+ /org/freedesktop/portal/desktop org.freedesktop.impl.portal.GlobalShortcuts \
+ CreateSession 'oosa{sv}' /r/1 /s/1 com.example 0
+busctl --user call … BindShortcuts 'ooa(sa{sv})sa{sv}' /r/1 /s/1 \
+ 1 quick 2 description s "Quick access" preferred_trigger s CTRL+SHIFT+space "" 0
+dbus-monitor --session "type='signal',interface='org.freedesktop.impl.portal.GlobalShortcuts'" &
+cce-shadow --instance agent-N run ../cce-compositor/verify/clients/target/release/vkey mod:5 57 mod:0
+```
+
+`vkey` (see `../cce-compositor/CLAUDE.md`, `verify/`) injects through the
+virtual-keyboard protocol, which is the same `handle_group_key` path hardware
+keys take; `ccectl keypress` goes straight to the focused client and never
+reaches the chord matcher, so it cannot be used for this.
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..b85c693
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "cce-shortcuts-portal"
+version = "0.1.0"
+edition = "2021"
+description = "org.freedesktop.impl.portal.GlobalShortcuts backend for the cce compositor"
+license = "GPL-3.0-only"
+
+[dependencies]
+zbus = { version = "5", default-features = false, features = ["tokio"] }
+serde = { version = "1", features = ["derive"] }
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync"] }
+log = "0.4"
+env_logger = "0.11"
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1ec1644
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,16 @@
+.PHONY: build install run clean
+
+build:
+ cargo build --release
+
+# Binaries, the dbus/ activation file and the portals/ declaration are all
+# enumerated by ccebuild, so nothing is named here.
+install: build
+ @command -v ccebuild >/dev/null || { echo "ccebuild not installed — run: make -C ../cce-compositor install"; exit 1; }
+ ccebuild install --no-build cce-shortcuts-portal
+
+run:
+ cargo run
+
+clean:
+ cargo clean
diff --git a/dbus/org.freedesktop.impl.portal.desktop.cce-shortcuts.service b/dbus/org.freedesktop.impl.portal.desktop.cce-shortcuts.service
new file mode 100644
index 0000000..2619b52
--- /dev/null
+++ b/dbus/org.freedesktop.impl.portal.desktop.cce-shortcuts.service
@@ -0,0 +1,8 @@
+# Bus activation for the GlobalShortcuts portal backend: xdg-desktop-portal
+# calls this name the first time an app asks for a global shortcut, and the
+# bus starts the backend. Exec must be absolute (D-Bus expands nothing), so
+# this names the ccebuild install location the way cce.portal's file chooser
+# does.
+[D-BUS Service]
+Name=org.freedesktop.impl.portal.desktop.cce-shortcuts
+Exec=/home/lsgalante/.local/bin/cce-shortcuts-portal
diff --git a/portals/cce-shortcuts.portal b/portals/cce-shortcuts.portal
new file mode 100644
index 0000000..7a9b073
--- /dev/null
+++ b/portals/cce-shortcuts.portal
@@ -0,0 +1,4 @@
+[portal]
+DBusName=org.freedesktop.impl.portal.desktop.cce-shortcuts
+Interfaces=org.freedesktop.impl.portal.GlobalShortcuts;
+UseIn=cce;
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..2b8f90f
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,391 @@
+//! cce-shortcuts-portal — the `org.freedesktop.impl.portal.GlobalShortcuts`
+//! backend for the cce compositor.
+//!
+//! A native Wayland app cannot grab a key; it asks xdg-desktop-portal to bind
+//! a trigger for it, and the portal frontend hands that to whichever backend
+//! `~/.config/xdg-desktop-portal/cce-portals.conf` names for the interface.
+//! This is that backend. It owns no key handling of its own: every trigger
+//! is forwarded to the compositor over its control socket (`shortcut bind
+//! <session> <id> <trigger>`), and every press comes back as a line on the
+//! status socket's `shortcuts` topic, which is re-emitted here as the
+//! portal's `Activated` / `Deactivated` signal. See `global_shortcuts.rs`
+//! in cce-compositor for the other end.
+//!
+//! Bus-activated (`dbus/…cce-shortcuts.service`), so it starts on the first
+//! request and lives as long as the compositor's status socket does: when
+//! that closes the process exits, and the next request starts a fresh one.
+//! A fresh one begins with `shortcut clear`, so binds left by a predecessor
+//! that died can never keep eating chords nobody is listening for.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use serde::Serialize;
+use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
+use tokio::sync::Mutex;
+use zbus::object_server::SignalEmitter;
+use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue, SerializeDict, Type, Value};
+use zbus::{interface, Connection, ObjectServer};
+
+const BUS_NAME: &str = "org.freedesktop.impl.portal.desktop.cce-shortcuts";
+const OBJ_PATH: &str = "/org/freedesktop/portal/desktop";
+
+/// Portal request responses.
+const RESPONSE_OK: u32 = 0;
+const RESPONSE_OTHER: u32 = 2;
+
+// ── wire types ─────────────────────────────────────────────────────────────
+
+/// One shortcut as the frontend wants it back: `(sa{sv})` with the two
+/// documented keys.
+#[derive(Clone, Debug, Serialize, Type)]
+struct BoundShortcut(String, ShortcutProps);
+
+#[derive(Clone, Debug, SerializeDict, Type)]
+#[zvariant(signature = "dict")]
+struct ShortcutProps {
+ description: String,
+ trigger_description: String,
+}
+
+/// The `results` vardict of BindShortcuts / ListShortcuts.
+#[derive(Clone, Debug, SerializeDict, Type)]
+#[zvariant(signature = "dict")]
+struct ShortcutsResults {
+ shortcuts: Vec<BoundShortcut>,
+}
+
+// ── state ──────────────────────────────────────────────────────────────────
+
+#[derive(Default)]
+struct Session {
+ #[allow(dead_code)]
+ app_id: String,
+ bound: Vec<BoundShortcut>,
+}
+
+#[derive(Default)]
+struct Shared {
+ sessions: HashMap<OwnedObjectPath, Session>,
+}
+
+type SharedState = Arc<Mutex<Shared>>;
+
+// ── compositor sockets ─────────────────────────────────────────────────────
+
+fn display() -> Option<String> {
+ std::env::var("WAYLAND_DISPLAY").ok().filter(|d| !d.is_empty())
+}
+
+/// The control socket, as `ccectl` resolves it.
+fn ctl_socket_path() -> String {
+ match display() {
+ Some(d) => format!("/tmp/cce-{d}.sock"),
+ None => "/tmp/cce.sock".to_string(),
+ }
+}
+
+/// The status socket (`status_server::get_status_socket_path`).
+fn status_socket_path() -> String {
+ match display() {
+ Some(d) => format!("/tmp/cce-status-interface-{d}.sock"),
+ None => "/tmp/cce-status-interface.sock".to_string(),
+ }
+}
+
+/// One request/reply round on the control socket. The compositor answers
+/// one line and closes, so the reply is everything up to EOF.
+async fn ctl(cmd: &str) -> std::io::Result<String> {
+ let mut stream = tokio::net::UnixStream::connect(ctl_socket_path()).await?;
+ stream.write_all(format!("{cmd}\n").as_bytes()).await?;
+ let mut reply = String::new();
+ stream.read_to_string(&mut reply).await?;
+ Ok(reply)
+}
+
+/// Shortcut ids are app-chosen and may contain anything, but the control
+/// socket splits its command on whitespace: percent-encode everything
+/// outside a safe set on the way out and decode on the way back. `+` is
+/// safe because the trigger string is built from it (`CTRL+SHIFT+space`)
+/// and the compositor parses the trigger as written; an id keeps its `+`
+/// too, which round-trips just the same. The compositor stores and echoes
+/// the encoded id (`ccectl shortcut list` shows `Quick%20Access`); only
+/// this side ever decodes.
+fn encode_token(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for b in s.bytes() {
+ if b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-' | b'~' | b'+') {
+ out.push(b as char);
+ } else {
+ out.push_str(&format!("%{b:02X}"));
+ }
+ }
+ out
+}
+
+fn decode_token(s: &str) -> String {
+ let bytes = s.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == b'%' && i + 2 < bytes.len() {
+ if let (Some(h), Some(l)) = (hex(bytes.get(i + 1)), hex(bytes.get(i + 2))) {
+ out.push(h << 4 | l);
+ i += 3;
+ continue;
+ }
+ }
+ out.push(bytes[i]);
+ i += 1;
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+fn hex(b: Option<&u8>) -> Option<u8> {
+ b.and_then(|b| (*b as char).to_digit(16)).map(|d| d as u8)
+}
+
+fn str_prop(props: &HashMap<String, OwnedValue>, key: &str) -> Option<String> {
+ match props.get(key).map(|v| &**v) {
+ Some(Value::Str(s)) => Some(s.to_string()),
+ _ => None,
+ }
+}
+
+// ── org.freedesktop.impl.portal.GlobalShortcuts ────────────────────────────
+
+struct Backend {
+ shared: SharedState,
+}
+
+#[interface(name = "org.freedesktop.impl.portal.GlobalShortcuts")]
+impl Backend {
+ /// Version 1: no `ConfigureShortcuts` (that is version 2), since there
+ /// is no configuration UI — a shortcut is bound to the trigger the app
+ /// preferred or not at all.
+ #[zbus(property)]
+ fn version(&self) -> u32 {
+ 1
+ }
+
+ async fn create_session(
+ &self,
+ #[zbus(object_server)] server: &ObjectServer,
+ _handle: ObjectPath<'_>,
+ session_handle: ObjectPath<'_>,
+ app_id: String,
+ _options: HashMap<String, OwnedValue>,
+ ) -> (u32, HashMap<String, OwnedValue>) {
+ let path = OwnedObjectPath::from(session_handle);
+ log::info!("CreateSession {} for app {:?}", path, app_id);
+ self.shared.lock().await.sessions.insert(path.clone(), Session { app_id, bound: Vec::new() });
+ let obj = SessionObj { path: path.clone(), shared: self.shared.clone() };
+ if let Err(e) = server.at(&path, obj).await {
+ log::error!("exporting session object {}: {}", path, e);
+ self.shared.lock().await.sessions.remove(&path);
+ return (RESPONSE_OTHER, HashMap::new());
+ }
+ (RESPONSE_OK, HashMap::new())
+ }
+
+ async fn bind_shortcuts(
+ &self,
+ _handle: ObjectPath<'_>,
+ session_handle: ObjectPath<'_>,
+ shortcuts: Vec<(String, HashMap<String, OwnedValue>)>,
+ _parent_window: String,
+ _options: HashMap<String, OwnedValue>,
+ ) -> (u32, ShortcutsResults) {
+ let session = OwnedObjectPath::from(session_handle);
+ if !self.shared.lock().await.sessions.contains_key(&session) {
+ log::warn!("BindShortcuts for unknown session {}", session);
+ return (RESPONSE_OTHER, ShortcutsResults { shortcuts: Vec::new() });
+ }
+ let mut bound = Vec::new();
+ for (id, props) in shortcuts {
+ let description = str_prop(&props, "description").unwrap_or_default();
+ // Without a preferred trigger there is nothing to bind to: a
+ // backend with a configuration dialog would ask the user here.
+ let Some(trigger) = str_prop(&props, "preferred_trigger") else {
+ log::warn!("{}: shortcut {:?} has no preferred_trigger; not bound", session, id);
+ continue;
+ };
+ let cmd = format!("shortcut bind {} {} {}", session, encode_token(&id), encode_token(&trigger));
+ match ctl(&cmd).await {
+ Ok(reply) if reply.starts_with("ok") => {
+ let trigger_description = reply[2..].trim().to_string();
+ log::info!("{}: bound {:?} to {}", session, id, trigger_description);
+ bound.push(BoundShortcut(id, ShortcutProps { description, trigger_description }));
+ }
+ Ok(reply) => log::warn!("{}: compositor refused {:?} ({}): {}", session, id, trigger, reply.trim()),
+ Err(e) => log::error!("control socket {}: {}", ctl_socket_path(), e),
+ }
+ }
+ if let Some(s) = self.shared.lock().await.sessions.get_mut(&session) {
+ s.bound = bound.clone();
+ }
+ (RESPONSE_OK, ShortcutsResults { shortcuts: bound })
+ }
+
+ async fn list_shortcuts(&self, _handle: ObjectPath<'_>, session_handle: ObjectPath<'_>) -> (u32, ShortcutsResults) {
+ let session = OwnedObjectPath::from(session_handle);
+ let shortcuts = self
+ .shared
+ .lock()
+ .await
+ .sessions
+ .get(&session)
+ .map(|s| s.bound.clone())
+ .unwrap_or_default();
+ (RESPONSE_OK, ShortcutsResults { shortcuts })
+ }
+
+ #[zbus(signal)]
+ async fn activated(
+ emitter: &SignalEmitter<'_>,
+ session_handle: ObjectPath<'_>,
+ shortcut_id: &str,
+ timestamp: u64,
+ options: HashMap<&str, Value<'_>>,
+ ) -> zbus::Result<()>;
+
+ #[zbus(signal)]
+ async fn deactivated(
+ emitter: &SignalEmitter<'_>,
+ session_handle: ObjectPath<'_>,
+ shortcut_id: &str,
+ timestamp: u64,
+ options: HashMap<&str, Value<'_>>,
+ ) -> zbus::Result<()>;
+
+ #[zbus(signal)]
+ async fn shortcuts_changed(
+ emitter: &SignalEmitter<'_>,
+ session_handle: ObjectPath<'_>,
+ shortcuts: Vec<BoundShortcut>,
+ ) -> zbus::Result<()>;
+}
+
+// ── org.freedesktop.impl.portal.Session ────────────────────────────────────
+
+/// One object per session, at the path the frontend chose. `Close` is how
+/// the app (or its death, via the frontend) gives its chords back.
+struct SessionObj {
+ path: OwnedObjectPath,
+ shared: SharedState,
+}
+
+#[interface(name = "org.freedesktop.impl.portal.Session")]
+impl SessionObj {
+ #[zbus(property)]
+ fn version(&self) -> u32 {
+ 1
+ }
+
+ async fn close(&self, #[zbus(connection)] conn: &Connection) {
+ log::info!("Close {}", self.path);
+ if let Err(e) = ctl(&format!("shortcut unbind {}", self.path)).await {
+ log::warn!("control socket {}: {}", ctl_socket_path(), e);
+ }
+ self.shared.lock().await.sessions.remove(&self.path);
+ // The object cannot remove itself from inside its own method call
+ // (the server holds it locked for the call), so it goes on the
+ // next turn of the loop.
+ let conn = conn.clone();
+ let path = self.path.clone();
+ tokio::spawn(async move {
+ if let Err(e) = conn.object_server().remove::<SessionObj, _>(&path).await {
+ log::warn!("removing session object {}: {}", path, e);
+ }
+ });
+ }
+
+ #[zbus(signal)]
+ async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
+}
+
+// ── main ───────────────────────────────────────────────────────────────────
+
+#[tokio::main]
+async fn main() {
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+ if let Err(e) = run().await {
+ log::error!("{e}");
+ std::process::exit(1);
+ }
+}
+
+async fn run() -> Result<(), Box<dyn std::error::Error>> {
+ // A predecessor that died mid-session leaves its chords bound in the
+ // compositor; nobody will ever hear them fire, so drop them before
+ // taking the bus name. This also proves the compositor speaks the
+ // command at all: an older one answers `error: unknown command`, and
+ // then this backend must not claim to serve anything.
+ let reply = ctl("shortcut clear")
+ .await
+ .map_err(|e| format!("control socket {}: {e}", ctl_socket_path()))?;
+ if !reply.starts_with("ok") {
+ return Err(format!("compositor does not support portal shortcuts: {}", reply.trim()).into());
+ }
+
+ // Subscribe to the press/release feed before serving, so a bind can
+ // never fire into a gap.
+ let status = tokio::net::UnixStream::connect(status_socket_path())
+ .await
+ .map_err(|e| format!("status socket {}: {e}", status_socket_path()))?;
+ let (reader, mut writer) = status.into_split();
+ writer.write_all(b"shortcuts\n").await?;
+
+ let shared: SharedState = Arc::new(Mutex::new(Shared::default()));
+ let conn = zbus::connection::Builder::session()?
+ .name(BUS_NAME)?
+ .serve_at(OBJ_PATH, Backend { shared: shared.clone() })?
+ .build()
+ .await?;
+ log::info!("serving {} at {}", BUS_NAME, OBJ_PATH);
+
+ let emitter = SignalEmitter::new(&conn, OBJ_PATH)?;
+ let mut lines = BufReader::new(reader).lines();
+ while let Some(line) = lines.next_line().await? {
+ let mut it = line.split_whitespace();
+ let (Some(kind), Some(session), Some(id)) = (it.next(), it.next(), it.next()) else {
+ log::warn!("unparseable status line {:?}", line);
+ continue;
+ };
+ let timestamp: u64 = it.next().and_then(|t| t.parse().ok()).unwrap_or(0);
+ let Ok(path) = ObjectPath::try_from(session) else {
+ log::warn!("bad session path in {:?}", line);
+ continue;
+ };
+ let id = decode_token(id);
+ let result = match kind {
+ "activated" => Backend::activated(&emitter, path, &id, timestamp, HashMap::new()).await,
+ "deactivated" => Backend::deactivated(&emitter, path, &id, timestamp, HashMap::new()).await,
+ other => {
+ log::warn!("unknown shortcut event {:?}", other);
+ Ok(())
+ }
+ };
+ if let Err(e) = result {
+ log::warn!("emitting {} for {}: {}", kind, session, e);
+ }
+ }
+ // The compositor closed the feed: every session is void with it.
+ log::info!("status socket closed; exiting");
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn token_round_trip() {
+ for s in ["quick-access", "Quick Access", "a/b%c", "üñî", "", "x%2", "%"] {
+ let enc = encode_token(s);
+ assert!(!enc.contains(char::is_whitespace));
+ assert_eq!(decode_token(&enc), s);
+ }
+ assert_eq!(encode_token("CTRL+SHIFT+space"), "CTRL+SHIFT+space", "triggers pass through untouched");
+ }
+}