git.lucas.co / cce-desktop-portal
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.