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

CLAUDE.md (5.8K)

  1 # CLAUDE.md
  2 
  3 Read `../cce-compositor/WORKSPACE.md` first: this crate is one member of the
  4 cce workspace and follows its multi-repo, `ccebuild` and concurrent-session
  5 rules.
  6 
  7 ## What this is
  8 
  9 `cce-shortcuts-portal` is the **`org.freedesktop.impl.portal.GlobalShortcuts`
 10 backend** for the cce compositor — the piece that lets a native Wayland app
 11 own a global hotkey. A Wayland client cannot grab keys; what it can do is ask
 12 xdg-desktop-portal to bind a trigger on its behalf (1Password's Quick Access,
 13 Ctrl+Shift+Space, is the motivating case: its binary calls this portal and
 14 nothing else). The portal *frontend* forwards that request to whichever
 15 backend the desktop's portals.conf names for the interface, and until this
 16 crate existed nothing on the machine declared it, so the frontend did not even
 17 publish `GlobalShortcuts` on the bus and every app's request failed silently.
 18 
 19 The backend is deliberately thin: it does no key handling. Both directions go
 20 through the compositor's existing sockets, and `src/server/global_shortcuts.rs`
 21 in cce-compositor is the other half of the contract:
 22 
 23 - **Binding** — `BindShortcuts` turns each shortcut's `preferred_trigger`
 24   (shortcuts-spec syntax, `CTRL+SHIFT+space`) into `shortcut bind <session>
 25   <id> <trigger>` on the control socket. The compositor parses the trigger,
 26   refuses a chord its own keybinds already use (the user's config wins, and
 27   the app is told it did not get the shortcut rather than having it silently
 28   shadowed), and answers with the `trigger_description` the app renders
 29   (`Ctrl+Shift+Space`). A shortcut without a `preferred_trigger` is not bound:
 30   there is no configuration dialog (interface version 1, no
 31   `ConfigureShortcuts`).
 32 - **Firing** — the backend subscribes to the status socket's `shortcuts`
 33   topic and re-emits each `activated|deactivated <session> <id> <time_msec>`
 34   line as the portal's `Activated` / `Deactivated` signal. Both edges are
 35   reported; neither reaches the focused client.
 36 - **Sessions** — one `org.freedesktop.impl.portal.Session` object per
 37   session at the path the frontend chose. `Close` unbinds that session's
 38   chords. Request objects are not exported: every call answers at once, so
 39   there is never a pending request to cancel.
 40 
 41 Shortcut ids are app-chosen and may contain spaces; the control socket
 42 splits on whitespace, so ids are percent-encoded on the wire and the
 43 compositor stores them encoded (`ccectl shortcut list` shows
 44 `Quick%20Access`). Only this side decodes. `+` is left alone so triggers
 45 pass through as written.
 46 
 47 ## Lifecycle
 48 
 49 Bus-activated: `dbus/org.freedesktop.impl.portal.desktop.cce-shortcuts.service`
 50 starts it on the first request. It begins with `shortcut clear`, which both
 51 drops chords a dead predecessor left bound (nobody would ever hear them
 52 fire) and proves the compositor speaks the command — an older compositor
 53 answers `error: unknown command`, and the backend exits rather than claim
 54 to serve an interface it cannot. It then lives as long as the status socket
 55 does and exits when the compositor closes it; the next request starts a
 56 fresh one. Sessions do not survive that, which is right: they were bound in
 57 a compositor that is gone.
 58 
 59 Reads `WAYLAND_DISPLAY` for both socket paths exactly as `ccectl` does, so
 60 it needs the bus activation environment to carry it (startcce imports it).
 61 Pointed at a `cce-shadow` instance's environment it drives that shadow
 62 instead — which is how it is verified.
 63 
 64 ## Wiring (three files, one of them not installed from here)
 65 
 66 - `portals/cce-shortcuts.portal` — declares the bus name and interface;
 67   `ccebuild install` puts it in `$XDG_DATA_HOME/xdg-desktop-portal/portals/`
 68   (the `portal_files()` step, added for this crate).
 69 - `dbus/…cce-shortcuts.service` — bus activation, installed to
 70   `~/.local/share/dbus-1/services/`. `Exec` is absolute because D-Bus
 71   expands nothing, the same as the file chooser's service file.
 72 - **`~/.config/xdg-desktop-portal/cce-portals.conf`** must name it:
 73   `org.freedesktop.impl.portal.GlobalShortcuts=cce-shortcuts`. That file is
 74   user config, not versioned anywhere; without the line the frontend's
 75   `default=gtk` applies and gtk does not implement the interface.
 76 
 77 xdg-desktop-portal reads portal files and the conf at startup:
 78 `systemctl --user restart xdg-desktop-portal` after installing, then
 79 `busctl --user introspect org.freedesktop.portal.Desktop
 80 /org/freedesktop/portal/desktop | grep GlobalShortcuts` shows whether the
 81 frontend now publishes it. An app that registered its shortcut before that
 82 (1Password at login) has to be restarted to ask again.
 83 
 84 ## Verifying without the live session
 85 
 86 The frontend is one per session bus, so the full path (app → frontend →
 87 backend) can only be exercised live. Everything below the frontend can be
 88 driven in a shadow, and that is where the two bugs so far were found
 89 (`+` being percent-encoded out of the trigger; a lone modifier accepted as
 90 a key):
 91 
 92 ```sh
 93 cce-shadow start --new                                   # prints agent-N
 94 cce-shadow --instance agent-N run target/release/cce-shortcuts-portal &
 95 busctl --user call org.freedesktop.impl.portal.desktop.cce-shortcuts \
 96   /org/freedesktop/portal/desktop org.freedesktop.impl.portal.GlobalShortcuts \
 97   CreateSession 'oosa{sv}' /r/1 /s/1 com.example 0
 98 busctl --user call … BindShortcuts 'ooa(sa{sv})sa{sv}' /r/1 /s/1 \
 99   1 quick 2 description s "Quick access" preferred_trigger s CTRL+SHIFT+space "" 0
100 dbus-monitor --session "type='signal',interface='org.freedesktop.impl.portal.GlobalShortcuts'" &
101 cce-shadow --instance agent-N run ../cce-compositor/verify/clients/target/release/vkey mod:5 57 mod:0
102 ```
103 
104 `vkey` (see `../cce-compositor/CLAUDE.md`, `verify/`) injects through the
105 virtual-keyboard protocol, which is the same `handle_group_key` path hardware
106 keys take; `ccectl keypress` goes straight to the focused client and never
107 reaches the chord matcher, so it cannot be used for this.