git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit09071f71772a191c7ea3c2c76bb17c03748cb4ee
parent41c9bdf460
authorLucas Galante <[email protected]>
date2026-09-24 16:41
Eat portal-bound global shortcuts for the GlobalShortcuts backend

A native Wayland app cannot grab a key; it asks xdg-desktop-portal's
GlobalShortcuts interface (1Password's Quick Access, Ctrl+Shift+Space),
and the new cce-shortcuts-portal backend forwards that here. This is the
compositor half:

- global_shortcuts.rs: a table of (session, id, mods, keysym) on the
  window manager and the 'shortcut bind|unbind|clear|list' control-socket
  command. bind parses a shortcuts-spec trigger (CTRL+SHIFT+space) and
  replies with the trigger_description the app renders; a chord the
  user's keybinds already use is refused rather than shadowed.
- keyboard_group.rs: the chord is matched after the builtins and config
  keybinds through the same two-level keysym lookup, now shared as
  match_chord, as KeyConsumer::PortalShortcut. Press and release are
  both eaten.
- status_server.rs: a 'shortcuts' topic carries one-shot
  'activated|deactivated <session> <id> <time_msec>' lines, which the
  backend re-emits as the portal signals.
- ccebuild installs <crate>/portals/*.portal declarations, and
  file_crate_dir learns the portals/ subdirectory so the package filter
  does not drop them.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md                      |  39 +++++++
 scripts/ccebuild               |  31 +++++-
 src/cce_ctl.rs                 |   2 +
 src/lib.rs                     |   2 +
 src/server/global_shortcuts.rs | 234 +++++++++++++++++++++++++++++++++++++++++
 src/server/keyboard_group.rs   |  78 +++++++++++---
 src/server/status_server.rs    |  59 ++++++++++-
 src/server/window_manager.rs   |   7 ++
 8 files changed, 432 insertions(+), 20 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2e56a6a..2ebc4cc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -71,6 +71,13 @@ It also installs the **`.desktop` entries** crates ship at their own root into
 in `~/.local/share/applications` until 2026-08-16; see `./WORKSPACE.md` for the
 `Exec=`/`MimeType=` rules that go with them.
 
+**Portal declarations** (`<crate>/portals/*.portal`, the file that tells
+xdg-desktop-portal a backend's bus name and interfaces) install to
+`$XDG_DATA_HOME/xdg-desktop-portal/portals/` the same filtered way
+(`portal_files()`); `cce-shortcuts-portal` ships the first. `file_crate_dir()`
+must know every such subdirectory name (`scripts`, `dbus`, `portals`) or the
+package filter reads the subdirectory as the crate and drops the file.
+
 **App icons** install from any crate's `hicolor/` tree (`app_icons()`), mirrored
 verbatim into `$XDG_DATA_HOME/icons/hicolor/` — so an icon's size and context are
 its directory, not a rule in the script, and `48x48/apps` would need no edit here.
@@ -703,6 +710,38 @@ headless seat has no keyboard and Chromium crashes in
   no committed buffer, an unsupported read format, an implausibly large strip —
   falls back to `backdrop::UNKNOWN`.
 
+### Portal global shortcuts
+
+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
+portal frontend hands that to a backend. `../cce-shortcuts-portal` is that
+backend and **`src/server/global_shortcuts.rs` is this side of it** — a
+table of `(session, id, mods, keysym)` on the window manager
+(`portal_shortcuts`) with a control-socket command to fill it and a status
+topic to report it:
+
+- `shortcut bind <session> <id> <trigger>` parses a shortcuts-spec trigger
+  (`CTRL+SHIFT+space`; modifiers `CTRL`/`ALT`/`SHIFT`/`LOGO`, key an xkb
+  keysym name) and replies `ok <trigger_description>` (`Ctrl+Shift+Space`)
+  or `error: …`. A chord in `keybinds` is refused — the user's config owns
+  it — as is one another session already holds. `unbind <session> [<id>]`,
+  `clear` and `list` are the rest. Nothing is persisted; the backend sends
+  `clear` when it starts.
+- The chord is matched in `handle_group_key` after the builtins and the
+  config keybinds, through the same two-level keysym lookup
+  (`keyboard_group::match_chord`, which `match_cce_keybind` now wraps), as
+  `KeyConsumer::PortalShortcut`. Press AND release are pushed as one-shot
+  lines on the status socket's `shortcuts` topic —
+  `activated|deactivated <session> <id> <time_msec>` — since the portal has
+  a `Deactivated` signal; neither edge reaches the client.
+
+The compositor never learns which app asked: the session object path is
+the only identity it carries, and it is one whitespace-free token, which is
+why ids come percent-encoded (`Quick%20Access`) and stay that way here.
+Drive it in a shadow with `ccectl shortcut bind /s/1 x CTRL+SHIFT+space`
+and `verify/clients`' `vkey mod:5 57` — not `ccectl keypress`, which goes
+straight to the focused client and never meets the chord matcher.
+
 ## Conventions
 
 - This is systems FFI code: raw pointers, `unsafe`, and manual wlroots listener wiring
diff --git a/scripts/ccebuild b/scripts/ccebuild
index 4b9306f..8930104 100755
--- a/scripts/ccebuild
+++ b/scripts/ccebuild
@@ -32,6 +32,7 @@ UNITDIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
 DESKTOPDIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
 ICONDIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons"
 DBUSDIR="${XDG_DATA_HOME:-$HOME/.local/share}/dbus-1/services"
+PORTALDIR="${XDG_DATA_HOME:-$HOME/.local/share}/xdg-desktop-portal/portals"
 
 die() { printf 'ccebuild: %s\n' "$*" >&2; exit 1; }
 
@@ -225,6 +226,18 @@ dbus_services() {
         -not -path "$WS/target/*" 2>/dev/null
 }
 
+# xdg-desktop-portal backend declarations a crate ships in its portals/ dir
+# (`<name>.portal`: the backend's bus name and the impl.portal interfaces it
+# serves), installed to $XDG_DATA_HOME/xdg-desktop-portal/portals where the
+# portal frontend reads them beside /usr/share's. The file only announces the
+# backend; the bus name it names must be activatable, which is the crate's
+# dbus/ service file. Which backend a desktop USES for an interface is
+# ~/.config/xdg-desktop-portal/cce-portals.conf, not installed from here.
+portal_files() {
+    find "$WS" -mindepth 3 -maxdepth 3 -path '*/portals/*.portal' -type f \
+        -not -path "$WS/target/*" 2>/dev/null
+}
+
 # XDG .desktop entries shipped by crates, at the crate root next to Cargo.toml.
 #
 # These used to be hand-written straight into ~/.local/share/applications, which
@@ -278,8 +291,12 @@ app_icons() {
 file_crate_dir() {
     local dir
     dir=$(basename "$(dirname "$1")")
+    # The subdirectories a crate ships installable files in; a file one level
+    # down reports the crate above. A new kind of shipped file needs its dir
+    # here, or its crate reads as e.g. "portals" and the package filter drops
+    # it silently (which is how the first portal declaration went uninstalled).
     case "$dir" in
-        scripts|dbus) dir=$(basename "$(dirname "$(dirname "$1")")") ;;
+        scripts|dbus|portals) dir=$(basename "$(dirname "$(dirname "$1")")") ;;
     esac
     printf '%s\n' "$dir"
 }
@@ -374,6 +391,18 @@ cmd_install() {
         dbus=$((dbus + 1))
     done < <(dbus_services)
 
+    local pf portals=0
+    while read -r pf; do
+        [ -n "$pf" ] || continue
+        # Same filtered install as the units above.
+        if [ ${#pkgs[@]} -gt 0 ]; then
+            crate_selected "$(file_crate_dir "$pf")" "${pkgs[@]}" || continue
+        fi
+        [ "$portals" -eq 0 ] && { printf '==> installing portal declarations -> %s\n' "$PORTALDIR"; mkdir -p "$PORTALDIR"; }
+        install -m 644 "$pf" "$PORTALDIR/$(basename "$pf")"
+        portals=$((portals + 1))
+    done < <(portal_files)
+
     local d desktops=0
     while read -r d; do
         [ -n "$d" ] || continue
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index 8a2fb57..36781b1 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -111,6 +111,8 @@ fn usage(name: &str, to_stderr: bool) {
     print("  key-down <keycode>                 (modifier codes — ctrl 29/97, shift 42/54,");
     print("  key-up <keycode>                    alt 56/100, super 125/126 — update client");
     print("                                      xkb state, so e.g. 29+36 lands as ctrl+j)");
+    print("  shortcut bind <session> <id> <trigger>   (portal GlobalShortcuts backend: CTRL+SHIFT+space)");
+    print("  shortcut unbind <session> [<id>] | clear | list");
 }
  
 pub fn run_cce_ctl(args: Vec<String>) {
diff --git a/src/lib.rs b/src/lib.rs
index 903d158..a6510d7 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -37,6 +37,8 @@ pub mod ipc_server;
 pub mod screenshot;
 #[path = "server/status_server.rs"]
 pub mod status_server;
+#[path = "server/global_shortcuts.rs"]
+pub mod global_shortcuts;
 #[path = "server/stream_server.rs"]
 pub mod stream_server;
 #[path = "server/scene_node_data.rs"]
diff --git a/src/server/global_shortcuts.rs b/src/server/global_shortcuts.rs
new file mode 100644
index 0000000..04d0713
--- /dev/null
+++ b/src/server/global_shortcuts.rs
@@ -0,0 +1,234 @@
+// SPDX-License-Identifier: GPL-3.0-only
+//! Portal global shortcuts — chords the compositor eats on behalf of the
+//! `org.freedesktop.impl.portal.GlobalShortcuts` backend (`cce-shortcuts-portal`).
+//!
+//! A native Wayland client cannot grab keys; what it can do is ask the
+//! desktop portal to bind a trigger for it (1Password's Quick Access does
+//! exactly this). The portal frontend forwards that to a backend, and the
+//! backend forwards it here over the control socket:
+//!
+//! ```text
+//! shortcut bind <session> <id> <trigger>   -> ok <trigger_description> | error: …
+//! shortcut unbind <session> [<id>]         -> ok
+//! shortcut clear                           -> ok
+//! shortcut list                            -> <session> <id> <trigger_description> per line
+//! ```
+//!
+//! `<session>` is the portal's session object path (no whitespace, so it is
+//! one token) and `<trigger>` is the shortcuts-spec string the app supplied
+//! (`CTRL+SHIFT+space`). A bound chord is matched in `handle_group_key`
+//! AFTER the builtins and the user's own keybinds — the user's config always
+//! wins, and a bind for a chord the config already uses is refused rather
+//! than silently shadowed, so the app is told it did not get it. Press and
+//! release are reported as one-shot lines on the status socket's
+//! `shortcuts` topic (`activated|deactivated <session> <id> <time_msec>`),
+//! which is where the backend turns them into the portal's `Activated` /
+//! `Deactivated` signals. The compositor never learns which app asked; the
+//! session path is the only identity it carries.
+//!
+//! The table is process state, not config: nothing here is persisted, and a
+//! backend that starts fresh sends `clear` first so a bind left by a dead
+//! predecessor cannot keep eating a chord nobody listens for.
+
+use crate::window_manager::WindowManager;
+
+/// One bound chord. `mods` is the wlr modifier mask (`config::parse_modifiers`
+/// values) and `keysym` an xkb keysym, exactly what `Keybind` carries, so the
+/// same matcher serves both tables.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PortalShortcut {
+    pub session: String,
+    pub id: String,
+    pub mods: u32,
+    pub keysym: u32,
+    /// The `trigger_description` handed back to the app: `Ctrl+Shift+Space`.
+    pub description: String,
+}
+
+/// A parsed shortcuts-spec trigger.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Trigger {
+    pub mods: u32,
+    pub keysym: u32,
+    pub description: String,
+}
+
+const MOD_SHIFT: u32 = 0x01;
+const MOD_CTRL: u32 = 0x04;
+const MOD_ALT: u32 = 0x08;
+const MOD_LOGO: u32 = 0x40;
+
+/// Parse a shortcuts-spec trigger: modifiers and one key joined by `+`, the
+/// modifiers being `CTRL`, `ALT`, `SHIFT` and `LOGO` (case-insensitive;
+/// `SUPER` and `META` are taken as `LOGO` since apps do write them) and the
+/// key an xkb keysym name (`space`, `F5`, `q`). A trailing `+` is the plus
+/// key itself, as in `CTRL++`.
+pub fn parse_trigger(s: &str) -> Result<Trigger, String> {
+    let s = s.trim();
+    if s.is_empty() {
+        return Err("empty trigger".into());
+    }
+    // `CTRL++` splits as ["CTRL", "", ""]: an empty last part after a `+`
+    // means the key is `+` itself.
+    let mut parts: Vec<&str> = s.split('+').collect();
+    let key = match parts.pop() {
+        Some("") if s.ends_with('+') => {
+            // Drop the empty part before it too (the one between the two
+            // plus signs), leaving just the modifiers.
+            parts.pop();
+            "plus"
+        }
+        Some(k) => k,
+        None => return Err("empty trigger".into()),
+    };
+    let mut mods = 0u32;
+    for m in parts {
+        let bit = match m.to_ascii_uppercase().as_str() {
+            "CTRL" | "CONTROL" => MOD_CTRL,
+            "ALT" => MOD_ALT,
+            "SHIFT" => MOD_SHIFT,
+            "LOGO" | "SUPER" | "META" => MOD_LOGO,
+            "" => return Err(format!("empty modifier in {s:?}")),
+            other => return Err(format!("unknown modifier {other:?}")),
+        };
+        mods |= bit;
+    }
+    let keysym: u32 = xkbcommon::xkb::keysym_from_name(key, xkbcommon::xkb::KEYSYM_CASE_INSENSITIVE).into();
+    if keysym == 0 {
+        return Err(format!("unknown key {key:?}"));
+    }
+    if unsafe { crate::keyboard::keysym_is_modifier(keysym) } {
+        return Err(format!("{key:?} is a modifier, not a key"));
+    }
+    Ok(Trigger { mods, keysym, description: describe(mods, keysym) })
+}
+
+/// Human form for the app to render: `Ctrl+Shift+Space`. Modifier order is
+/// fixed regardless of how the trigger was written.
+fn describe(mods: u32, keysym: u32) -> String {
+    let mut out = Vec::new();
+    if mods & MOD_CTRL != 0 {
+        out.push("Ctrl".to_string());
+    }
+    if mods & MOD_ALT != 0 {
+        out.push("Alt".to_string());
+    }
+    if mods & MOD_SHIFT != 0 {
+        out.push("Shift".to_string());
+    }
+    if mods & MOD_LOGO != 0 {
+        out.push("Super".to_string());
+    }
+    let name = xkbcommon::xkb::keysym_get_name(xkbcommon::xkb::Keysym::new(keysym));
+    // Single letters read better upper-case; multi-letter names (`space`,
+    // `Return`, `F5`) get an initial capital and are otherwise left alone.
+    let mut chars = name.chars();
+    let pretty = match chars.next() {
+        Some(c) if name.chars().count() == 1 => c.to_uppercase().collect::<String>(),
+        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
+        None => name.clone(),
+    };
+    out.push(pretty);
+    out.join("+")
+}
+
+/// The `shortcut …` control-socket command. `args` is everything after the
+/// word `shortcut`.
+pub fn ipc(wm: &mut WindowManager, args: &[&str]) -> String {
+    match args.first().copied() {
+        Some("bind") => {
+            let [_, session, id, trigger] = args else {
+                return "error: usage: shortcut bind <session> <id> <trigger>\n".to_string();
+            };
+            let t = match parse_trigger(trigger) {
+                Ok(t) => t,
+                Err(e) => return format!("error: {e}\n"),
+            };
+            // The user's config owns its chords: a portal bind never
+            // shadows one, and the app hears that it was refused.
+            if wm.keybinds.iter().any(|kb| kb.mods == t.mods && kb.keysym == t.keysym) {
+                return format!("error: {} is a compositor keybind\n", t.description);
+            }
+            if let Some(other) = wm
+                .portal_shortcuts
+                .iter()
+                .find(|s| s.mods == t.mods && s.keysym == t.keysym && !(s.session == *session && s.id == *id))
+            {
+                return format!("error: {} is already bound by {} {}\n", t.description, other.session, other.id);
+            }
+            // Re-binding the same (session, id) replaces its chord.
+            wm.portal_shortcuts.retain(|s| !(s.session == *session && s.id == *id));
+            log::info!("[shortcut] bind {} {} -> {}", session, id, t.description);
+            wm.portal_shortcuts.push(PortalShortcut {
+                session: session.to_string(),
+                id: id.to_string(),
+                mods: t.mods,
+                keysym: t.keysym,
+                description: t.description.clone(),
+            });
+            format!("ok {}\n", t.description)
+        }
+        Some("unbind") => {
+            let (session, id) = match args {
+                [_, session] => (*session, None),
+                [_, session, id] => (*session, Some(*id)),
+                _ => return "error: usage: shortcut unbind <session> [<id>]\n".to_string(),
+            };
+            let before = wm.portal_shortcuts.len();
+            wm.portal_shortcuts.retain(|s| !(s.session == session && id.map_or(true, |id| s.id == id)));
+            log::info!("[shortcut] unbind {} {}: {} removed", session, id.unwrap_or("*"), before - wm.portal_shortcuts.len());
+            "ok\n".to_string()
+        }
+        Some("clear") => {
+            log::info!("[shortcut] clear: {} removed", wm.portal_shortcuts.len());
+            wm.portal_shortcuts.clear();
+            "ok\n".to_string()
+        }
+        Some("list") => {
+            let mut out = String::new();
+            for s in &wm.portal_shortcuts {
+                out.push_str(&format!("{} {} {}\n", s.session, s.id, s.description));
+            }
+            out
+        }
+        _ => "error: usage: shortcut bind|unbind|clear|list\n".to_string(),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_spec_triggers() {
+        let t = parse_trigger("CTRL+SHIFT+space").unwrap();
+        assert_eq!(t.mods, MOD_CTRL | MOD_SHIFT);
+        assert_eq!(t.keysym, u32::from(xkbcommon::xkb::keysyms::KEY_space));
+        assert_eq!(t.description, "Ctrl+Shift+Space");
+    }
+
+    #[test]
+    fn modifier_spelling_is_lenient_and_order_fixed() {
+        let a = parse_trigger("shift+logo+q").unwrap();
+        let b = parse_trigger("SUPER+SHIFT+Q").unwrap();
+        assert_eq!(a.mods, b.mods);
+        assert_eq!(a.keysym, b.keysym, "keysym lookup is case-insensitive");
+        assert_eq!(a.description, "Shift+Super+Q");
+    }
+
+    #[test]
+    fn plus_key_and_bare_key() {
+        assert_eq!(parse_trigger("CTRL++").unwrap().description, "Ctrl+Plus");
+        let f5 = parse_trigger("F5").unwrap();
+        assert_eq!(f5.mods, 0);
+        assert_eq!(f5.description, "F5");
+    }
+
+    #[test]
+    fn rejects_garbage() {
+        assert!(parse_trigger("").is_err());
+        assert!(parse_trigger("HYPER+a").is_err());
+        assert!(parse_trigger("CTRL+nosuchkey").is_err());
+        assert!(parse_trigger("CTRL+Shift_L").is_err(), "a lone modifier key is not a chord");
+    }
+}
diff --git a/src/server/keyboard_group.rs b/src/server/keyboard_group.rs
index a3eed0f..ec6395e 100644
--- a/src/server/keyboard_group.rs
+++ b/src/server/keyboard_group.rs
@@ -13,6 +13,10 @@ pub enum KeyConsumer {
     Builtin,
     Binding(*mut XkbBinding),
     CceBinding(crate::config::Keybind),
+    /// A chord bound through the GlobalShortcuts portal backend (see
+    /// `global_shortcuts`): the press and the release are both reported on
+    /// the status socket's `shortcuts` topic and neither reaches the client.
+    PortalShortcut { session: String, id: String },
     EnsureEaten,
     ImGrab,
     Focus,
@@ -415,6 +419,9 @@ unsafe extern "C" fn handle_group_key(listener: *mut ffi::wl_listener, data: *mu
         } else if let Some(kb) = match_cce_keybind(&(*(*group.seat).server).wm, xkb_keycode, modifiers, xkb_state) {
             log::debug!("matched CCE monolithic keybind: {:?}", kb);
             KeyConsumer::CceBinding(kb)
+        } else if let Some((session, id)) = match_portal_shortcut(&(*(*group.seat).server).wm, xkb_keycode, modifiers, xkb_state) {
+            log::debug!("matched portal shortcut {} {}", session, id);
+            KeyConsumer::PortalShortcut { session, id }
         } else if let Some(binding) = (*group.seat).match_xkb_binding(xkb_keycode, &mut group.wlr_keyboard) {
             log::debug!("matched xkb binding");
             (*group.seat).xkb_bindings_seat.ensure_next_key_eaten = false;
@@ -477,6 +484,21 @@ unsafe extern "C" fn handle_group_key(listener: *mut ffi::wl_listener, data: *mu
                 wm.execute_action(&action, kb.command.as_deref());
             }
         }
+        KeyConsumer::PortalShortcut { session, id } => {
+            // Both edges go out: the portal has a Deactivated signal, and
+            // the release comes back here through the consumer map with the
+            // same variant the press recorded.
+            let pressed = (*event).state == ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_PRESSED;
+            if let Some(ref sender) = (*(*group.seat).server).wm.status_sender {
+                sender.send_shortcut_event(&format!(
+                    "{} {} {} {}",
+                    if pressed { "activated" } else { "deactivated" },
+                    session,
+                    id,
+                    (*event).time_msec
+                ));
+            }
+        }
         KeyConsumer::Binding(binding) => {
             if !binding.is_null() {
                 if (*event).state == ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_PRESSED {
@@ -524,6 +546,42 @@ pub unsafe fn match_cce_keybind(
     modifiers: u32,
     xkb_state: *mut ffi::xkb_state,
 ) -> Option<crate::config::Keybind> {
+    match_chord(keycode, modifiers, xkb_state, |mods, sym| {
+        wm.keybinds.iter().find(|kb| kb.mods == mods && kb.keysym == sym).cloned()
+    })
+}
+
+/// The portal-bound chords (`global_shortcuts`), matched exactly like the
+/// config keybinds but consulted after them. Returns `(session, id)`.
+pub unsafe fn match_portal_shortcut(
+    wm: &crate::window_manager::WindowManager,
+    keycode: u32,
+    modifiers: u32,
+    xkb_state: *mut ffi::xkb_state,
+) -> Option<(String, String)> {
+    if wm.portal_shortcuts.is_empty() {
+        return None;
+    }
+    match_chord(keycode, modifiers, xkb_state, |mods, sym| {
+        wm.portal_shortcuts
+            .iter()
+            .find(|s| s.mods == mods && s.keysym == sym)
+            .map(|s| (s.session.clone(), s.id.clone()))
+    })
+}
+
+/// Chord lookup shared by every (mods, keysym) table. `probe` is asked
+/// twice over: first with the keycode's level-0 keysyms against the raw
+/// modifier mask (so `super+shift+h` matches on `h`, not `H`), then with the
+/// keysyms of the level the modifiers actually select against the mask with
+/// the consumed modifiers removed (so a bind on a shifted symbol like
+/// `plus` still fires). The first hit wins.
+unsafe fn match_chord<T>(
+    keycode: u32,
+    modifiers: u32,
+    xkb_state: *mut ffi::xkb_state,
+    probe: impl Fn(u32, u32) -> Option<T>,
+) -> Option<T> {
     if xkb_state.is_null() {
         return None;
     }
@@ -537,13 +595,9 @@ pub unsafe fn match_cce_keybind(
     let num_syms = ffi::xkb_keymap_key_get_syms_by_level(keymap, keycode, layout, 0, &mut syms_ptr);
     if num_syms > 0 && !syms_ptr.is_null() {
         let syms = std::slice::from_raw_parts(syms_ptr, num_syms as usize);
-        for kb in &wm.keybinds {
-            if kb.mods == modifiers {
-                for &sym in syms {
-                    if sym == kb.keysym {
-                        return Some(kb.clone());
-                    }
-                }
+        for &sym in syms {
+            if let Some(hit) = probe(modifiers, sym) {
+                return Some(hit);
             }
         }
     }
@@ -555,13 +609,9 @@ pub unsafe fn match_cce_keybind(
         let syms = std::slice::from_raw_parts(syms_ptr_level, num_syms_level as usize);
         let consumed = ffi::xkb_state_key_get_consumed_mods2(xkb_state, keycode, ffi::xkb_consumed_mode_XKB_CONSUMED_MODE_XKB);
         let modifiers_translated = modifiers & !consumed;
-        for kb in &wm.keybinds {
-            if kb.mods == modifiers_translated {
-                for &sym in syms {
-                    if sym == kb.keysym {
-                        return Some(kb.clone());
-                    }
-                }
+        for &sym in syms {
+            if let Some(hit) = probe(modifiers_translated, sym) {
+                return Some(hit);
             }
         }
     }
diff --git a/src/server/status_server.rs b/src/server/status_server.rs
index f122c14..fe020bf 100644
--- a/src/server/status_server.rs
+++ b/src/server/status_server.rs
@@ -2,7 +2,7 @@
 //
 // Runs in a dedicated thread. cce-status connects to
 // /tmp/cce-status-{WAYLAND_DISPLAY}.sock, sends a subscription line
-// ("layout", "title", "modifiers", "adjust", or "dismiss") and receives lines whenever the status changes.
+// ("layout", "title", "modifiers", "adjust", "dismiss", or "shortcuts") and receives lines whenever the status changes.
 //
 // The main loop sends updates through an mpsc channel. The server thread
 // owns the socket and handles all I/O independently of the Wayland event loop.
@@ -49,6 +49,10 @@ pub enum StatusMsg {
     /// subscriber EXCEPT the segment whose app_id is carried here should
     /// close its open menu (the exempt segment saw the press itself).
     MenuDismiss { except_app_id: String },
+    /// A portal-bound chord went down or up (`global_shortcuts`): one line,
+    /// `activated|deactivated <session> <id> <time_msec>`, for every
+    /// `shortcuts` subscriber — in practice the one portal backend.
+    Shortcut(String),
 }
 
 /// Subscription types that the status bar script can request.
@@ -64,6 +68,9 @@ enum Subscription {
     Adjust,
     /// One-shot menu-dismiss events only — never receives state pushes.
     Dismiss,
+    /// `shortcuts` — one-shot portal shortcut press/release lines only
+    /// (see `StatusMsg::Shortcut`); never receives state pushes.
+    Shortcuts,
     /// `backdrop <app_id>` — what THIS segment is composited over, so it can
     /// adapt its own text contrast. Lines are `<luma> <spread>`, both 0-100.
     Backdrop(String),
@@ -85,6 +92,7 @@ impl Subscription {
             "modifiers" => Subscription::Modifiers,
             "adjust" => Subscription::Adjust,
             "dismiss" => Subscription::Dismiss,
+            "shortcuts" => Subscription::Shortcuts,
             _ => Subscription::Unknown,
         }
     }
@@ -131,6 +139,15 @@ impl StatusSender {
     }
 }
 
+impl StatusSender {
+    /// Report a portal shortcut edge to every `shortcuts` subscriber.
+    pub fn send_shortcut_event(&self, line: &str) {
+        if self.tx.send(StatusMsg::Shortcut(line.to_string())).is_ok() {
+            wake_fd(&self.wake);
+        }
+    }
+}
+
 impl Drop for StatusSender {
     /// Dropping the last handle disconnects the channel; the thread only
     /// notices when it next wakes, so give it one.
@@ -309,6 +326,7 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
 
         // Process incoming updates from the main loop
         let mut dismiss_events: Vec<String> = Vec::new();
+        let mut shortcut_events: Vec<String> = Vec::new();
         loop {
             match rx.try_recv() {
                 Ok(StatusMsg::State(update)) => {
@@ -318,6 +336,9 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
                 Ok(StatusMsg::MenuDismiss { except_app_id }) => {
                     dismiss_events.push(except_app_id);
                 }
+                Ok(StatusMsg::Shortcut(line)) => {
+                    shortcut_events.push(line);
+                }
                 Err(mpsc::TryRecvError::Empty) => break,
                 Err(mpsc::TryRecvError::Disconnected) => {
                     log::info!("[status] channel disconnected, exiting");
@@ -356,15 +377,43 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
             }
         }
 
+        // Portal shortcut edges go only to `shortcuts` subscribers, in order.
+        if !shortcut_events.is_empty() {
+            let mut dead_clients = Vec::new();
+            for (i, client) in clients.iter_mut().enumerate() {
+                if client.subscription != Subscription::Shortcuts {
+                    continue;
+                }
+                for line in &shortcut_events {
+                    match client
+                        .stream
+                        .write_all(line.as_bytes())
+                        .and_then(|_| client.stream.write_all(b"\n"))
+                    {
+                        Ok(_) => {}
+                        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
+                        Err(_) => {
+                            dead_clients.push(i);
+                            break;
+                        }
+                    }
+                }
+            }
+            dead_clients.dedup();
+            for i in dead_clients.into_iter().rev() {
+                clients.remove(i);
+            }
+        }
+
         // If we got a new update, push it to all clients
         if has_new_update {
             if let Some(ref update) = latest {
                 let mut dead_clients = Vec::new();
 
                 for (i, client) in clients.iter_mut().enumerate() {
-                    // Dismiss subscribers get one-shot events only, never
-                    // state pushes.
-                    if client.subscription == Subscription::Dismiss {
+                    // Dismiss and shortcuts subscribers get one-shot events
+                    // only, never state pushes.
+                    if matches!(client.subscription, Subscription::Dismiss | Subscription::Shortcuts) {
                         continue;
                     }
                     let msg = format_for_subscription(&client.subscription, update);
@@ -444,7 +493,7 @@ fn format_for_subscription(sub: &Subscription, update: &StatusUpdate) -> String
                 None => "unknown".to_string(),
             }
         }
-        Subscription::Dismiss | Subscription::Unknown => String::new(),
+        Subscription::Dismiss | Subscription::Shortcuts | Subscription::Unknown => String::new(),
     }
 }
 
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 3ced192..4c9a0d9 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -158,6 +158,9 @@ pub struct WindowManager {
     pub keybinds: Vec<crate::config::Keybind>,
     pub pointer_binds: Vec<crate::config::PointerBind>,
     pub gesture_binds: Vec<crate::config::GestureBind>,
+    /// Chords bound through the GlobalShortcuts portal backend — see
+    /// `global_shortcuts`. Matched after `keybinds`, never persisted.
+    pub portal_shortcuts: Vec<crate::global_shortcuts::PortalShortcut>,
     pub ipc_rx: Option<std::sync::mpsc::Receiver<crate::ipc_server::IpcRequest>>,
     /// The IPC thread's wake eventfd as a wl_event_loop fd source: fires once
     /// per queued request, so the drain runs only when there is something to
@@ -624,6 +627,7 @@ impl WindowManager {
         self.keybinds = Vec::new();
         self.pointer_binds = Vec::new();
         self.gesture_binds = Vec::new();
+        self.portal_shortcuts = Vec::new();
         self.ipc_rx = None;
         self.ipc_source = std::ptr::null_mut();
         self.ipc_wake = None;
@@ -6220,6 +6224,9 @@ impl WindowManager {
                     "error: invalid keycode\n".to_string()
                 }
             }
+            // Portal global shortcuts (the `cce-shortcuts-portal` backend's
+            // half of the contract lives in `global_shortcuts`).
+            "shortcut" => crate::global_shortcuts::ipc(self, &parts[1..]),
             _ => format!("error: unknown command: {}\n", action),
         }
     }