git.lucas.co / cce-shortcuts-portal
GlobalShortcuts portal backend
git clone https://git.lucas.co/cce-shortcuts-portal.git

commit7a432654b3352ef6fe1b7264bb82dbad700531a4
authorLucas Galante <[email protected]>
date2026-09-24 15:05
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");
+    }
+}