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.