Settings and Inhibit portal backends
git clone https://git.lucas.co/cce-desktop-portal.git
CLAUDE.md (8.3K)
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-desktop-portal` is cce's own backend for five portal interfaces that
10 used to fall to xdg-desktop-portal-gtk through `default=gtk`, plus the
11 `org.freedesktop.ScreenSaver` service apps call directly:
12
13 - **`org.freedesktop.impl.portal.Settings`** (`src/settings.rs`) — the
14 `org.freedesktop.appearance` namespace from cce's config instead of GNOME's
15 GSettings: `color-scheme` from `style { color_scheme }` (absent = dark),
16 `accent-color` from `style { highlight primary }`, `reduced-motion` from the
17 animations switch `/run/cce/animations`, `contrast` always normal. Only that
18 namespace: the frontend merges every backend `cce-portals.conf` lists for
19 Settings (`cce-desktop;gtk`), first answer per key wins, and an unknown key
20 must fail with `org.freedesktop.portal.Error.NotFound` so the frontend
21 moves on to gtk for the `org.gnome.*` keys sandboxed GTK apps read.
22 Changes are pushed by inotify on the two directories (not the files —
23 config.kdl is replaced by rename), and only keys whose value moved signal.
24 - **`org.freedesktop.impl.portal.Inhibit`** and **`org.freedesktop.ScreenSaver`**
25 (`src/inhibit.rs`) — keeping the display awake for apps that ask over
26 D-Bus. gtk's Inhibit forwarded to `org.gnome.SessionManager` /
27 `org.freedesktop.ScreenSaver`, neither of which a cce session runs, so every
28 request was accepted and did nothing. Each holder here becomes a **lease**
29 in the compositor: `idle inhibit <token> <ttl_s> <who>` on the control
30 socket (`src/server/idle.rs` in cce-compositor is the other half). Leases
31 lapse unless renewed, so the backend renews every live one every 20 s
32 against a 60 s ttl; if it dies, the compositor drops them within a minute.
33 A hold ends when the portal request is closed (the frontend does that when
34 the app exits), on `UnInhibit`, or — ScreenSaver — when the caller's bus
35 connection goes away. Only the portal's idle (8) and suspend (4) flags
36 hold anything; logout and user-switch have nothing to hold in cce.
37
38 - **`org.freedesktop.impl.portal.AppChooser`** (`src/app_chooser.rs`) — the
39 "Open with…" dialog. The frontend passes the handler IDs; this runs
40 `cce-cloud --choose -p "Open <file> with: " [-s <last_choice>]` with the IDs
41 on stdin (cce-cloud's chooser mode shows each entry's name and icon and
42 prints the picked ID — see its CLAUDE.md) and answers `{choice: <id>}`, or
43 cancelled on Escape. The request's `Close` kills the cce-cloud client; the
44 cce-cloud daemon closes a popup whose client hung up. `UpdateChoices` is
45 only logged. `CCE_CHOOSER_BIN` points a run at another cce-cloud build.
46 - **`org.freedesktop.impl.portal.Notification`** (`src/notification.rs`) —
47 portal notifications as cce-notifier cards: `AddNotification` becomes
48 `Notify` on `org.freedesktop.Notifications` (desktop-entry name as
49 app_name, `markup-body` stripped, the icon as a theme name or a file under
50 `$XDG_RUNTIME_DIR/cce-desktop-portal/` for bytes/fd icons, `priority` →
51 urgency, `default-action` as the `default` key and buttons as `b<i>`);
52 the server's `ActionInvoked` comes back through those keys as the
53 portal's `ActionInvoked(app_id, id, action, [target])`. Needs cce-notifier
54 with actions and signals (its `feat: clickable cards…` commit) — before
55 that, cards dropped every action.
56 - **`org.freedesktop.impl.portal.Print`** (`src/print.rs`) — the dialog is a
57 `cce-cloud --json` panel: a destination list ("Save as PDF", then each
58 CUPS queue from `lpstat -e`, default first) leading to one options page
59 per destination (copies, all pages / from–to, two-sided where `lpoptions
60 -l` shows a Duplex option), each ending in its own `print:<i>` button —
61 the panel reports only the closing button and every control's value, so
62 per-destination pages and `<i>.`-prefixed ids are what say which
63 destination was chosen. `PreparePrint` answers GtkPrintSettings (ranges
64 0-based) and a token; `Print` with that token sends the fd's document
65 with `lp -d … -n … [-o sides=two-sided-long-edge]` (ranges are already
66 rendered by the app), or for Save as PDF copies it to the path
67 `cce-files --save` returned. A `Print` without a token asks first.
68 `CCE_PRINT_DRY_RUN` logs the `lp` command instead of running it;
69 `CCE_FILES_BIN` swaps the save dialog (a stub that echoes a path is how
70 the save branch is tested — typing `/` in cce-files opens its location
71 search, so a path cannot be typed into its name box). The CUPS branch
72 was verified against a cups-pdf queue (`sudo lpadmin -p cce-test-pdf -E
73 -v cups-pdf:/ -m CUPS-PDF_opt.ppd`; output in
74 `/var/spool/cups-pdf/$USER/`): 2 copies came out as a 2-page PDF titled
75 after the job. cups-pdf has no Duplex option, so its page shows no
76 two-sided box.
77
78 `idle status` names holders as `portal:<who>`.
79
80 ## Lifecycle
81
82 One process, bus-activated by either name (two files in `dbus/`); whichever
83 is asked for first starts it and it claims both. A racing second activation
84 finds the portal name taken and exits. It begins with `idle inhibit-clear`
85 (a predecessor's leases die now rather than at their ttl; an older
86 compositor answers an error, and leases then wait for a newer one — they
87 are still tracked and sent on every renew). It lives for the session:
88 Settings must be there to signal changes. At rest it does nothing — inotify
89 for the config, and the renew loop sleeps while nothing is held.
90
91 Reads `WAYLAND_DISPLAY` for the control socket path exactly as `ccectl`
92 does, so the bus activation environment must carry it (startcce imports it).
93
94 ## Wiring
95
96 - `portals/cce-desktop.portal` → `$XDG_DATA_HOME/xdg-desktop-portal/portals/`
97 - `dbus/*.service` → `~/.local/share/dbus-1/services/` (absolute `Exec`)
98 - **`~/.config/xdg-desktop-portal/cce-portals.conf`** (user config, not
99 versioned) must say `org.freedesktop.impl.portal.Settings=cce-desktop;gtk`,
100 `org.freedesktop.impl.portal.Inhibit=cce-desktop`,
101 `org.freedesktop.impl.portal.AppChooser=cce-desktop` and
102 `org.freedesktop.impl.portal.Notification=cce-desktop`.
103
104 The frontend reads portal files and the conf at startup. Restarting it
105 (`systemctl --user restart xdg-desktop-portal`) drops every app's portal
106 sessions — 1Password's global shortcut among them, until it is restarted —
107 so prefer letting the next login pick changes up.
108
109 ## Verifying
110
111 The session bus is shared with the live session (a shadow has none of its
112 own), so check first that `org.freedesktop.ScreenSaver` and the portal name
113 are unowned, run the backend through `cce-shadow run` so its control socket
114 is the shadow's, and kill it after. Leases can be driven directly with
115 `cce-shadow ctl idle inhibit t1 2 tester` (watch it lapse in `idle status`).
116 A ScreenSaver holder that must outlive one call needs a client that keeps
117 its connection (`busctl call` exits at once and its hold is released with
118 it — correctly). To test the frontend's Settings merge without restarting
119 the live one, run a private `dbus-run-session` with this backend,
120 `/usr/lib/xdg-desktop-portal-gtk` and `xdg-desktop-portal -r` under an
121 `XDG_CONFIG_HOME` holding a test `cce-portals.conf`, and kill everything
122 the private bus activated afterwards (cce-shortcuts-portal attaches to the
123 shadow and keeps `dbus-run-session` alive).
124
125 The chooser can be driven end to end the same way, never on the live bus:
126 a private `dbus-run-session` running this backend with
127 `WAYLAND_DISPLAY=<shadow's>` and `CCE_CHOOSER_BIN=<tree cce-cloud>`, a
128 `gdbus call … AppChooser.ChooseApplication /test/r/1 app "" "['gimp',…]"
129 "{'filename': <'/x/a.pdf'>}"` in the background, then `cce-shadow ctl
130 keypress` (108 Down, 28 Enter, 1 Escape) and read gdbus's reply. cce-cloud's
131 client socket is keyed by `WAYLAND_DISPLAY`, so a shadow's chooser runs
132 standalone (or under a shadow daemon) and never reaches the live one.
133
134 Notifications the same way: the private bus also runs a tree
135 `cce-notifier` (it owns `org.freedesktop.Notifications` there, so the live
136 one is never asked), `gdbus monitor --dest` each name into a file, post
137 with `AddNotification`, and click the cards with `cce-shadow ctl
138 pointer-move-to` / `pointer-click [right]`. The notifier reads the real
139 config, so its bell may sound. When cleaning up, match the private bus by
140 its socket path in `/proc/<pid>/environ` — a `pkill -f` on that path also
141 matches the shell running it.