cloud storage client
git clone https://git.lucas.co/cce-cloud.git
CLAUDE.md (21.3K)
1 # CLAUDE.md
2
3 This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
5 ## What this is
6
7 `cce-cloud` is the **launcher / popup app** of the cce Wayland desktop environment: a
8 fuzzel-style fuzzy launcher, a dmenu replacement, a Super-Tab window switcher, and a generic
9 JSON-defined popup panel — all in one binary. It is one crate of the cce multi-repo
10 workspace; workspace-wide conventions (multi-repo layout, no `[workspace.dependencies]`,
11 shared `../target/`) live in **`../cce-compositor/WORKSPACE.md`** — read that too.
12
13 Two source files, all logic in `src/main.rs` (~5.6k lines):
14
15 - `src/main.rs` — CLI parsing, daemon/client/standalone entry points, the Wayland
16 surface and event loop, `FuzzelWidget` (the list UI: keyboard selection chip,
17 pointer hover wash, icons, row labels — hover and click share one `row_at`
18 predicate, so the lit row is the row a press picks), all input handling,
19 app/PATH scanning. (Rendering itself is cce-ui's — this crate declares no
20 graphics dependency of its own.)
21 - `src/json_layout.rs` — `JsonLayoutWidget`: the JSON-config popup panel host
22 (labels, checkboxes, buttons, spinboxes, color selectors, sliders, multi-page).
23 App-owned copy of a dissolved cce-ui type; cloud is its only consumer.
24
25 The list's scrollbar/viewport math is the toolkit's `cce_ui::widget::ScrollRegion`
26 (`cce-ui/src/widget/scroll_region.rs`). This crate carried its own copy in
27 `src/scroll_region.rs` until 74f9ad4 (2026-09-01); the shared one is what gives
28 `get_draw_y` its partially-visible-rows contract, which paint, click and hover all
29 virtualize on.
30
31 Its bar is the DE's one scrollbar (cce-ui's CLAUDE.md, "Every scrollbar rides a
32 centre line, behind the plate"): `with_sink_behind(true)`, so it rides the
33 list's centre line over the rows, idles under the list's translucent bg fill
34 (`push_scrollbar_prims` before the fill, every frame) and fades in over the
35 rows while a scroll holds it (`push_scrollbar_fore` after them). Until
36 2026-10-06 it sat at the right edge as flat squares on a hard flip, through the
37 tuple path (`push_quads` / `push_scrollbar_quads`).
38
39 ## Build, test, run
40
41 ```sh
42 cargo build # standalone (this repo is its own workspace root)
43 cargo build -p cce-cloud # from the workspace root (avoids compositor rebuild)
44 cargo test # unit tests live at the bottom of main.rs
45 cargo test test_json_layout # single test
46 make install # release build, then `ccebuild install --no-build cce-cloud`
47 ```
48
49 Running requires a Wayland session (layer-shell). Quick manual checks:
50
51 ```sh
52 cce-cloud --path # launcher over $PATH executables
53 printf "a\nb\nc\n" | cce-cloud --dmenu # dmenu mode; selection prints to stdout
54 echo '{"widgets":[{"type":"button","id":"ok","text":"OK"}]}' | cce-cloud --json
55 cce-cloud --daemon # run the daemon (normally started by the DE)
56 ```
57
58 ## Architecture
59
60 ### Daemon / client / standalone
61
62 `main()` (bottom of `src/main.rs`) picks one of three roles:
63
64 - `--daemon`: `run_daemon()` binds `/run/user/$UID/cce-cloud.socket` (fallback
65 `/tmp/cce-cloud-$UID.socket`), holds one long-lived Wayland connection, and serves
66 **one popup at a time, serially**. A new client connection **preempts** the currently
67 open popup (global single-popup semantics); a client hangup closes the popup
68 (`StdinState.client_gone`).
69 - No `--daemon`: `run_client()` connects to that socket, sends one JSON line
70 `{"args": [...], "initial_stdin": "..."}`, streams any further stdin lines over the
71 socket, and blocks until the daemon writes the selection back — which it prints to
72 stdout. So callers get dmenu semantics whether or not the daemon is running.
73 - If the socket connect fails, it falls back to `run_standalone()`: the same UI
74 in-process, selection printed directly to stdout.
75
76 **The daemon's life is one compositor's.** It holds one Wayland connection for good,
77 so it has to notice that connection dying: the wait for the next request dispatches the
78 Wayland source alongside the socket listener (a calloop `Generic` on a dup of it), and a
79 dispatch error — the compositor gone — is `compositor_gone`, a clean exit with status 0.
80 The unit is `PartOf=` / `WantedBy=cce-session.target`, which startcce starts and stops
81 with each compositor, so the next session brings up a fresh daemon. Until 2026-09-25 the
82 wait was a blocking `accept()` that never read the Wayland socket, and the unit hung off
83 `graphical-session.target`, whose stop systemd skipped at one logout (a queued dropbox
84 start made the transaction "destructive"): the daemon survived into the next session and
85 panicked building its first popup on the dead connection (`No surface formats:
86 ERROR_SURFACE_LOST_KHR`). A unit's `[Install]` change needs `systemctl --user reenable
87 cce-cloud.service` — `ccebuild install` copies units but does not re-enable them.
88
89 The daemon re-parses the forwarded args with the same flag loop as standalone — **flag
90 changes must be made in both `run_standalone()` and `run_daemon()`** (and, for the
91 needs-stdin decision, in `run_client()`).
92
93 ### Modes (`LauncherMode`)
94
95 - `Dmenu` — items from stdin, selection echoed out. Magic stdin lines
96 `__cce_switcher_next__` / `__cce_switcher_select_and_close__` drive the compositor's
97 window switcher (`--switcher` starts in Dmenu with its hold modifier down, and commits when neither Super nor Alt is held, so it works bound to Super+Tab or Alt+Tab; moving the pointer onto a row selects it — `FuzzelWidget::pointer_moved`, motion only, so a popup mapping under a resting pointer does not steal the row Super+Tab advanced to).
98 - `--choose` — the **app chooser** ("Open with…") behind the AppChooser portal
99 (cce-desktop-portal spawns `cce-cloud --choose [-p prompt] [-s last_id]`).
100 Dmenu mode with a different feed and answer: stdin carries desktop-file IDs,
101 each row shows that entry's `Name=` and icon (`desktop_entry_label`, NoDisplay
102 entries included — the portal's handler list is authoritative), and the pick
103 is printed back as the ID, not the label (`Chooser::answer`). `-s` takes an
104 ID too. Two entries sharing a name get `Name (id)` labels so every row maps
105 back to one app. Escape answers an empty line, which the portal reads as
106 cancelled. `State::new` takes `chooser_mode` because it ingests stdin
107 once itself: state set after construction misses that first feed and
108 shows the raw IDs.
109 - `Path` — executables scanned from `$PATH`.
110 **Key repeat** is the client's job on Wayland: the keyboard is bound with
111 `get_keyboard_with_repeat` on the calloop loop, which re-fires a held key at
112 the compositor's `repeat_info` rate into `repeat_key`. Only text, deletion
113 and cursor/list movement repeat (`key_repeats`); Return, Escape and Tab do
114 not. The loop is created BEFORE `AppState` on both paths, because the first
115 roundtrip is when the seat announces its keyboard. Until 2026-09-26 it was
116 bound with plain `get_keyboard`, and a held Backspace deleted one character.
117
118 - `Apps` — `.desktop` files from the standard application dirs, sorted by launch
119 frecency persisted in `~/.cache/cce-cloud-apps.json`; selecting spawns the app's
120 `Exec` (spawn output logged to `$XDG_RUNTIME_DIR/cce/spawn.log`, via
121 `cce_ui::config::cce_runtime_dir()` — it was `/tmp/cce-spawn.log` before
122 2026-08-22). Every launch (`spawn_detached`) runs in **its own transient
123 scope**, `app-cce\x2dcloud-<app>-<n>.scope` in app.slice — `<app>` the
124 program the launch runs, not the `sh -c` wrapping it (`launch_name`) — `PartOf=`
125 `cce-session.target` (`scope_argv`, through `systemd-run --scope`): a daemon
126 restart never reaches it, and the session's end stops it. Until 2026-09-26
127 launches stayed in cce-cloud.service's own cgroup, where `KillMode=process`
128 (there so a restart would not kill them) also let them outlive logout — a
129 queued Proton launch then started in the next session before Xwayland
130 existed and ran invisible. Without `systemd-run` on PATH the app is started
131 directly, as before. Each entry's `Icon=` is
132 resolved through `cce_ui::icon` and drawn in a gutter left of the label — the
133 gutter is applied to every row, so one unresolvable icon doesn't rag the text
134 edge. Apps and the Super-Tab switcher are the only lists with icons: other
135 Dmenu/Path items are arbitrary strings with nothing to look up, and their
136 gutter stays 0. The switcher's rows are the compositor's `Title (app_id)`
137 lines; `split_switcher_row` splits one into title and id. The row text itself
138 is left as sent, since the compositor maps the echoed text back to a window —
139 only the drawn and measured label drops the suffix (`row_label`), so typing
140 an app_id still filters. `desktop_icon_index` maps the id to a `.desktop` entry's `Icon=` by file stem,
141 `StartupWMClass`, or last reverse-DNS component, falling back to the app_id
142 itself, which is the name cce's own apps install their icons under. The rows
143 stream in over stdin, so `resolve_switcher_icons` runs on each ingest.
144 Both paths first ask `icon_override` for a cce-icons glyph named after the
145 entry's **desktop-file ID** (`$XDG_DATA_HOME/icons/hicolor/scalable/apps/<id>.svg`,
146 one stat), which is how cce draws its own icon for an entry whose `Icon=` is
147 an absolute path, missing, or a generic name several apps share — see
148 `cce-icons/hicolor/README.md`. Overrides named after the `Icon=` value need
149 nothing here: the user's data dir is the theme search's first base dir.
150 Icons are uploaded once per renderer, not per popup: `icon_image` caches
151 the image id by icon name for as long as `cce_ui::vk::renderer_epoch` holds,
152 which in the daemon is its whole life (see "One renderer for every popup"
153 below), and the daemon warms it from a thread at startup. Uploading per
154 popup cost a synchronous GPU copy per icon at the first frame and a
155 device-idle wait per free at the next: ~90 stalls for 46 icons, most of the
156 launcher's time to first frame until 2026-10-05.
157 Apps is also the one **tabbed** mode: the list carries an *Apps* page and a
158 *System* page of DE verbs (`SYSTEM_COMMANDS` — window-manager actions through
159 `ccectl`, plus session/power commands), and **Tab / Shift+Tab step between
160 them**. See "Tabs" below.
161 - `Json` — a `JsonLayoutConfig` read from stdin builds a widget panel; clicking a
162 button prints one JSON object with the button id and every control's state
163 (`{"button", "checkboxes", "spinboxes", "colors", "sliders"}`) and closes.
164 A button with `target_page` turns the panel to that page instead.
165 **A button's marks and page turns are cce-icons glyphs**, by cce-ui's
166 context-menu conventions (`json_layout::button_glyphs`; since 2026-10-05):
167 a `text` that BEGINS with `"✓ "`, `"● "` or `"○ "` (`context_menu::MARK_CHECK`
168 / `MARK_ON` / `MARK_OFF`) is drawn with the check / circle / circle-outline
169 glyph and the text without the mark; a `target_page` LOWER than the
170 button's own page is a back row, chevron-left at its left; any other
171 `target_page` leads to a page, chevron-right at its right end. A page where
172 any button has a left glyph reserves the column on all of them, so the
173 labels share an edge, and the popup's width budgets the glyphs. Nothing new
174 in the schema — the marks are the toolkit's, the direction is the
175 `target_page` the protocol already had — so a layout must NOT spell its own
176 (`"Window Mode >"`, `"< Back"`, `"[x] "`), or it shows them twice; a back
177 row's text is a word ("Back"), which is also all that shows if the icon set
178 is missing. `cce-compositor/scripts/cce-desktop-menu` and `cce-app-menu`
179 are the consumers that page. Glyphs draw through `PaintCtx::icon`, whose
180 upload rides the shared queue this app's renderer drains each frame.
181
182 Key flags: `-p/--prompt`, `-s/--select <item>`, `-x/-y` (position → forces layer-shell
183 anchoring), `--align-right`, `--parent-app-id` (app_id becomes `cce-cloud:<parent>`),
184 `--switcher`, `--choose`, and mode flags `--apps|--path|--dmenu|--json` (or `--mode <m>`).
185
186 ### Tabs
187
188 `FuzzelWidget::tabs` splits the list into `TabPage`s. Fewer than two draws no
189 strip and claims no height (`tab_strip_h()` is 0), which is what leaves Dmenu,
190 Path and the Super-Tab window switcher laid out and keyed exactly as they were —
191 **Tab only switches tabs where tabs exist**, and falls back to its old job of
192 cycling the highlight everywhere else. That fallback is load-bearing: the
193 switcher is Dmenu mode, and its Tab is the key the whole feature is named for.
194
195 Three things to know before touching it:
196
197 - **The active page's items and query live in `all_items` / `query`, not in its
198 `TabPage`.** Every pre-existing caller reads them there, and only
199 `switch_tab` moves them across — so a page's own copies are stale for as long
200 as it is the active one. Parking the query is what makes switching back land
201 on the same filtered rows.
202 - **The stdin/socket feed addresses tab 0, through `set_tab_items`,** never
203 `set_items` directly. `check_stdin_updates` skips the ingest when the items
204 match what it last pushed; compared against the *active* tab that test would
205 differ on every poll and clobber the page the user is reading.
206 - **A tab click is handled in the pointer branch, ahead of the row branch** —
207 next to the scrollbar press, and for the same reason. Any press
208 `FuzzelWidget::on_event` resolves is treated there as a row selection and (in
209 Dmenu/switcher mode) committed, so a tab click routed through it would choose
210 a row and close the popup.
211
212 The list geometry is derived from `search_y()` / `list_y()` / `list_h()` rather
213 than the `let pad = 15.0; let search_h = 35.0;` locals that used to be repeated
214 in each of the paint, scroll and hit-test paths: the strip shifts the whole list
215 down by its own height, and a path that missed the shift would put the rows, the
216 clip and the click out of step. The icon gutter is per-tab
217 (`recompute_icon_gutter`), so the System page's rows sit flush left while the
218 Apps page keeps its column — and so is the row height: `item_h()` is
219 `ICON_ITEM_H` (36, for a 26px icon) while a gutter exists and `ITEM_H` (25)
220 otherwise. Every index→y path reads `item_h()`, and a gutter change re-runs
221 `update_scroll`, because the icons usually land after `set_items` has already
222 sized the scroll bounds for text rows.
223
224 The strip is painted by hand in the toolkit's recessed `ButtonStrip` idiom — one
225 well carved into the window plate, segments on its floor, the active one a
226 raised `control_plate` — because this widget paints straight onto the `PaintCtx`
227 and has no child layout pass to host a real `ButtonStrip`.
228
229 `SYSTEM_COMMANDS` is hardcoded, not config-driven: the rows are the DE's own
230 verbs, and a row naming a command `ccectl` does not have is one that silently
231 does nothing when picked (`every_system_row_runs_something` pins the lookup the
232 commit path makes). The window verbs act on the window *behind* the popup — the
233 compositor's `focused_window` skips overlay UI and names `cce-cloud` among it,
234 falling back to the most recent real window — which is the only reason "Close
235 Window" from a launcher means anything.
236
237 ### Rendering: hand-rolled loop on `cce_ui::vk`, not the cce-ui engine runner
238
239 Unlike most cce clients, this app does **not** implement the `Application` trait or use
240 `cce_ui::engine::run`. It owns its event loop directly (smithay-client-toolkit handlers
241 + calloop) and renders through **`cce_ui::vk::VkRenderer`** (the toolkit's raw-Vulkan
242 backend): `collect_display_list` builds a `PaintCtx` (beveled window plate, then the
243 active widget's `paint_self` walk — bevel/recess prims included), `upload_vertices`
244 runs it through `tessellate_display_list` into `State::vertex_data` + `frame_batches`
245 (`Batch2D`, physical-px scissors) + `plate_features`, `prepare_text` builds `TextSpan`s
246 from the paint walk (buffers shaped at logical size, spans scaled to physical), and
247 `render` is `draw_frame_2d(Frame2D { .. })`. Nothing in this path knows about the
248 open/close fade any more — see "Fading in and out" below. `tessellate` must carry the tessellator's image
249 list across to `Frame2D::images`: images ride a separate pipeline from the vertex
250 batches, and that return value was dropped (with `images: &[]` hardcoded) until
251 2026-08-16, which made `PaintCtx::image` a silent no-op *in this app only* while
252 it worked in every engine-runner client. `State::renderer` is an `Option`
253 so the daemon can take it back when a popup closes, and so `Drop` can tear the
254 swapchain down before destroying the `wl_surface` in standalone mode. It still reuses cce-ui pieces à la carte: the
255 narrow widget traits (`Layout`/`Paint`/`Input` via `Adapted<T>`), the scene paint walk
256 (`append_widget_text`) for text extraction, `color`/`layout`/`scale` getters, and the
257 `zcce_window_manager_v1` protocol. Follow existing cce-ui conventions when touching
258 widget code, but don't try to "port" this app onto the engine runner.
259
260 ### One renderer for every popup
261
262 The daemon keeps one `VkRenderer` for its whole life, like the font system:
263 `State::new` takes it and moves it onto the popup's new `wl_surface` with
264 `VkRenderer::attach_surface`, and when the popup closes the daemon takes it back
265 and calls `detach_surface` BEFORE the `State` drops (the drop destroys the
266 `wl_surface`, and a swapchain must not outlive it). `run_daemon` builds it at
267 startup on a scratch surface that is never mapped, so the first popup attaches
268 too. Until 2026-10-05 every popup built a new renderer, and its pipelines were
269 70-100 ms of a ~100 ms popup on an idle machine and several hundred under
270 load; a reopen is now one swapchain, and a menu popup is ready in ~2 ms and on
271 screen in 15-40 ms, the launcher in 30-60. **A popup draws nothing before its
272 surface's first configure** (`State::configured`, checked in `State::render`):
273 a buffer attached ahead of a layer surface's configure is a protocol error,
274 and the compositor disconnects the daemon, and with it every popup after.
275 The renderer used to take long enough to build that the configure always
276 won; once it was kept, the Super-Tab switcher's streamed rows asked for a
277 frame first and killed the daemon on its first open (fixed the same day,
278 before it reached a release that stayed installed). Note a shadow's idle
279 timeout turns its output off after 10 minutes, after which xdg toplevels
280 (the launcher) are never configured at all; `ccectl idle timeouts 0 0`
281 first. `open -> first frame with rows` is logged for list popups: a
282 streamed list's first frame can be empty. An image uploaded to it lives as
283 long as it does: upload through a cache like `icon_image`, never per popup,
284 or free it at close. Standalone mode still builds its own.
285
286 Surface choice: an XDG toplevel flagged as popup via the cce window-management
287 protocol when the compositor global is present and no `-x/-y` was given; otherwise a
288 layer-shell **Overlay** surface with exclusive keyboard. The window continuously
289 auto-sizes to its content (`update_desired_size` — list rows measured with
290 `cce_ui::cosmic_text` buffers, the JSON panel's widgets with
291 `cce_ui::widget::display::measure_text`)
292 and closes through the DE-wide dissolve (`trigger_close`, below).
293
294 ### Fading in and out
295
296 The popup dissolves in when it maps and back out when it closes, and **neither
297 is drawn by this app** — both are the compositor ramping the opacity of the
298 surface's scene node, which carries the backdrop blur behind the popup down
299 with it. All this side does on close is ask and then wait:
300 `trigger_close` calls `cce_ui::ipc::request_close_fade()` (one `fade-out` line
301 on the compositor's control socket, answered with a duration in ms), stores
302 the deadline in `fade_until`, and keeps the surface mapped until the loop
303 sees it pass. Nothing is redrawn in between — the pixels stay put while the
304 scene node fades under them.
305
306 This replaced a hand-rolled fade that multiplied every vertex and image alpha
307 by a factor and then dropped the SDF-plate batches outright. A plate batch
308 **is** its cover quad, and the window background is a plate, so the first
309 frame of every close fade deleted the entire background and left the rows and
310 text dissolving over nothing. That is the failure mode to remember: per-element
311 alpha cannot express a window fade, because a client's surface stays fully
312 present to the compositor no matter how transparent it draws itself — the blur
313 behind it does not fade, and shader-lit output (plate rims, specular) never
314 honoured the vertex alpha in the first place.
315
316 `-x/-y` is a *cursor* position (the compositor hands the raw pointer to the desktop
317 and window-border context menus), not a final window origin — `Placement` fits it to
318 the output: grow away from the anchor, flip to its other side when the window would
319 overhang, clamp only when it fits on neither side, with the flip latched for the
320 popup's life so an auto-sizing list can't snap back and forth across the cursor.
321 Because the size is not known until the content is measured, the anchor/margins are
322 set by `apply_placement()` after the first `update_desired_size()` and re-applied by
323 `resize_window()` on every subsequent resize — a one-shot placement at surface
324 creation would use the pre-layout estimate and clip. The surface asks for
325 `exclusive_zone(-1)` so that the box it is clamped against (the wl_output logical
326 geometry) is the same one the compositor places it in.
327
328 ### State flow
329
330 Stdin/socket items land in a shared `Arc<Mutex<StdinState>>` written by a reader
331 thread; a calloop channel pings the main loop, which ingests via
332 `check_stdin_updates()` → refilter → resize → re-upload vertices. Rendering is
333 demand-driven off a `redraw` flag with a 16ms dispatch timeout.