web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
CLAUDE.md (33.9K)
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-browser` is a web browser for the CCE Wayland desktop environment, built on
8 **embedded WPE WebKit** (since 2026-08-30; the original Servo backend survives behind
9 a feature flag — see WPE-PORT.md for the whole port). It is one crate of the multi-repo `cce` workspace (its
10 own git repo side-by-side with its siblings; `origin` is the local *bare* repo
11 `~/git/cce-browser.git`, so **committing is not publishing — `git push origin master`
12 is**, after which gitsite mirrors it to `https://git.lucas.co/cce-browser.git`, the old
13 fetch-only static mirror now kept as the `published` remote). Read the workspace-level
14 `../cce-compositor/WORKSPACE.md` first: workspace layout, the `cce-ui` toolkit, config
15 conventions, and the multi-repo rules all live there.
16
17 Sixteen files, ~8.8k lines. The ten that carry the design:
18
19 | file | what it owns |
20 | --- | --- |
21 | `src/main.rs` | `BrowserApp` — the `cce-ui` `Application`: chrome layout, hit-testing, the URL line editor, key/pointer routing |
22 | `src/instance.rs` | single-instance forwarding: a later launch hands its argument to the running instance's socket and exits |
23 | `src/bin/open.rs` | `cce-browser-open`, the desktop entry's `Exec` target: a ~500KB forwarder linking only libc (~4ms vs ~22ms through the full binary), exec'ing `cce-browser` when no instance answers |
24 | `src/webview.rs` | **retired backend, behind the non-default `servo` feature** — `ServoHost`: Servo boot, the delegate, one `WebView` per tab, the frame pipeline. Not built by `cargo build`; see WPE-PORT.md |
25 | `src/pages.rs` | the `cce:` protocol handler and its History / Bookmarks / Favorites stores |
26 | `src/downloads.rs` | the chrome-side download pipeline (Servo has none) |
27 | `src/session.rs` | open-tab persistence: the tab set survives a restart |
28 | `src/settings.rs` | the per-app KDL config |
29 | `src/accounts.rs` | accounts from cce-secrets: the Secret Service worker, and which entries a host earns |
30 | `src/wpe/formwatch.rs` | the page half of account autocomplete: the watcher script, the fill script, and the events between them |
31
32 ## Build
33
34 The **default build is the WPE WebKit browser**: seconds to compile, ~14 MB linked
35 against the system `libWPEWebKit` (`pacman -S wpewebkit` is the one prerequisite).
36 That default is deliberate and load-bearing — while WPE was opt-in, a routine
37 featureless rebuild by another session silently reverted the installed browser to
38 Servo within two days. WORKSPACE.md's old "leave cce-browser out of `cce-ui` sweeps"
39 rule was about Servo's build cost and no longer applies to the default build.
40
41 ```sh
42 cargo build --release -p cce-browser # WPE WebKit (default); shared ../target/
43 cargo test --release -p cce-browser # release, or it builds Servo-debug from scratch
44 ccebuild install --no-build cce-browser # install binary + desktop entry
45 ```
46
47 **`--no-default-features --features servo` builds the retired Servo backend**, and
48 *that* is the expensive one: it compiles Servo (more than the rest of the workspace
49 combined, ~175 MB binary). Only pay for it deliberately. A last-known-good Servo
50 binary sits at `~/.local/state/cce/browser/cce-browser-servo-fallback`.
51
52 There is **no `Makefile`** here (most siblings have one) — install goes
53 through `ccebuild` directly. `Cargo.lock` is gitignored in this crate. Running needs a
54 live Wayland session; it will not run headless.
55
56 The engine-specific sections below (frame pipeline, tabs, key routing) describe the
57 **Servo backend** (`src/webview.rs`, feature `servo`); the WPE equivalents live in
58 `src/wpe/` and are documented in WPE-PORT.md. `servo = "0.4"` comes from crates.io,
59 not a git pin. Servo's embedding API churns
60 hard between releases, so when a version bump breaks the build, expect the delegate
61 trait, the input-event constructors, and `Preferences`/`Opts` to be where it broke.
62
63 ## Single instance
64
65 An external open (the desktop entry's `%u`) spawns a fresh process per link.
66 `src/instance.rs` turns that into a tab: `main()` tries
67 `/tmp/cce-browser-<WAYLAND_DISPLAY>.sock` (the standard `cce_ui::ipc`
68 convention; display keying isolates shadow sessions) before any engine or
69 Wayland work, forwards `open <arg>` / `new-tab` and exits on success, or binds
70 the socket and becomes the instance. The desktop entry's `Exec` is
71 **`cce-browser-open`** (`src/bin/open.rs`), a forwarder that links only libc —
72 the full binary spends ~20ms loading libWPEWebKit before `main()` runs, the
73 slim bin forwards in ~4ms — and execs `cce-browser` when nothing answers. It
74 deliberately duplicates the tiny client protocol rather than import anything;
75 keep it, `instance.rs`, and the socket-path convention in agreement. The listener thread pushes
76 `Message::OpenExternal` into calloop; `update()` parses the relayed argument
77 with `parse_startup_arg` — it *is* a launch argument, so the URL bar's
78 domain-guess parsing stays wrong for it — and a forwarded relative file path is
79 canonicalized on the *sending* side, whose cwd it is relative to. Beyond
80 tidiness this guards the profile dir: two engines must not share the plaintext
81 cookie jar. There is deliberately no `--new-window` yet. On every forwarded
82 open the app asks the compositor to `focus-window cce-browser` over the
83 control socket — focus pans the camera to the window, which is what makes a
84 forwarded link *visible*; without it the tab opens in a window parked
85 off-camera and the click looks inert (that shipped for half a day). The
86 compositor's xdg-activation is not the route: it deliberately answers with an
87 attention notification, not focus.
88
89 The other half of click-to-tab latency is inside WebKit: creating a webview
90 and spawning its WebProcess is ~200ms, so the WPE host keeps a hidden **spare
91 webview** prewarmed on about:blank and `open_tab` adopts it (see the `spare`
92 field in `src/wpe/host.rs`). Measured end to end: a link is a live tab in
93 ~65ms (internal page) / ~120ms (example.com, warm) against ~250/~400ms
94 without. WPE's platform API has no
95 `webkit_web_context_prewarm_spare_web_process` (the GTK port's answer), which
96 is why the spare is hand-rolled.
97
98 ## The frame pipeline
99
100 Servo renders into a **`SoftwareRenderingContext`** (CPU, no GPU handoff), one context
101 shared by every tab. `paint_active()` paints the active webview into it and calls
102 `read_to_image` — **without `present()`**, deliberately, because presenting would
103 release the buffer this needs to read. The pixels upload via `cce_ui::vk::upload_rgba`
104 and the page draws in `display_list` as a single full-bleed quad.
105
106 Everything is on the main thread. Servo's internal threads wake calloop through
107 `Waker` → `Message::Spin` → `ServoHost::pump()`, which spins Servo's loop, drains
108 delegate signals, and repaints only if the *active* tab flagged a frame.
109
110 Two consequences worth holding onto:
111
112 - **Registry images leak unless freed.** Every `image` replacement and every
113 `close_tab` calls `cce_ui::vk::free_image` on the old id. New code that swaps a tab's
114 frame must do the same.
115 - **An idle page produces no frames**, so nothing turns the loop on its own. Anything
116 time-based (see the force-dark reload deadlines) has to spawn a thread that sends
117 `Message::Spin` when the deadline passes, or it simply never fires.
118
119 ## The frame pipeline on WPE
120
121 WebKit renders into a **mappable SHM buffer** (`toplevel_formats` asks for
122 one; DMABuf import is still the Phase 2 in WPE-PORT.md), `read_shm` copies it
123 out, and the pixels are drawn as one full-bleed quad. What that path costs is
124 worth knowing, because it is paid on **every frame of every scroll** and this
125 display is 3840x2400 — 35 MB a frame fullscreen.
126
127 Three things it deliberately does *not* do any more, each measured at that
128 size before it went:
129
130 - **No CPU swizzle.** `WPE_PIXEL_FORMAT_ARGB8888` is BGRA in memory and is
131 handed over as `PixelFormat::Bgra`; the sampler reads either channel order
132 at no cost. Rearranging the bytes cost **7.4 ms a frame**.
133 - **No per-frame allocation.** The destination comes from
134 `cce_ui::vk::recycle_buffer`, and the sink refills the *superseded* frame's
135 buffer rather than dropping it — when the engine outruns `pump`, which is
136 exactly when frames are being thrown away, allocating a new buffer each time
137 would be the most expensive possible way to discard work. A fresh Vec per
138 frame was **4.5 ms**, nearly all zeroing and page faults.
139 - **No copy for `sample_pixel`.** It used to clone the whole frame to answer a
140 three-byte question (only `examples/wpe_dark.rs` asks); `last_pixel` keeps
141 the three bytes instead. That clone was **7 ms a frame**.
142
143 And on the GPU side `pump` calls `update_pixels` when the tab already has an
144 image of the same size, so the frame replaces the contents of one texture
145 instead of creating an image and freeing last frame's — that free took
146 `device_wait_idle`, once per frame. Only a resize (or a tab's first frame)
147 takes the create path.
148
149 What is left per frame: one memcpy out of SHM (whole-buffer when the stride is
150 tight, per row otherwise), one memcpy into the shared staging buffer, and the
151 transfer. The remaining `queue_wait_idle` inside the toolkit's update is
152 explained there. **Do not reintroduce a `Vec` allocation, a swizzle, or a
153 second copy on this path without measuring** — the numbers above are what each
154 one costs.
155
156 ### The readback is paced to draws
157
158 `render_buffer` says the two halves of the buffer protocol at different times,
159 and that is the pacing. `wpe_view_buffer_rendered` — *displayed* — is said at
160 once, so the engine's own frame pacing never waits on us.
161 `wpe_view_buffer_released` — *the memory is yours again* — waits until the
162 pixels have been copied out, which happens in `pump`, not in the callback.
163 (Saying neither is what stalls the engine after exactly one frame; that is what
164 the old comment here warned about.)
165
166 Holding the buffer buys two things. A frame superseded before anyone read it is
167 handed back **unread**, so several frames dispatched inside one pump's drain
168 cost one copy rather than N. And `pending_draw` gates the readback on the
169 chrome having actually drawn (`frame_drawn`, called from `display_list`): while
170 nothing has drawn the last frame, the next one is left held rather than copied
171 over a picture nobody saw.
172
173 That second half is where the win is, and it is not the one first expected.
174 Measured in a shadow against a page animating at 63 fps: **visible and drawing,
175 62 of 63 frames are read — the pacing changes nothing**, because `pump` is what
176 dispatches the engine's frames and it dispatches them promptly, so the engine
177 never gets ahead. **Minimized, 4 of 64 are read** — 60 handed back unread, a
178 page that used to cost its full window size sixty times a second while nobody
179 was looking. Restoring recovers the full rate within a second, with live
180 content.
181
182 `CCE_BROWSER_FRAME_DEBUG=1` logs the two counts once a second; the gap between
183 them is invisible from the outside, since a browser that skips nine frames in
184 ten looks exactly like one that copies all ten.
185
186 ## Tabs
187
188 One `WebView` per tab, all sharing the single rendering context; only the active one
189 is shown, focused, sized and painted (servoshell's model). Each `Tab` keeps its last
190 frame, so switching shows content instantly while the resize refreshes it.
191
192 - The delegate cannot touch `ServoHost` (it is held by Servo), so it records into
193 `HostShared` — a `dirty` flag plus a per-`WebViewId` `TabSignals` map — which `pump`
194 polls and folds into the `Tab` structs. New page state goes in `TabSignals`, not in
195 a delegate callback that tries to mutate the host.
196 - `TabSignals::loading` is `Option<bool>` on purpose: a defaulted `false` would read as
197 "finished loading" and swallow the true→false transition that history recording
198 keys on.
199 - `active: usize::MAX` is a **sentinel**, set in `new()` and again in `close_tab`, so
200 `activate()` does the full show/focus/resize dance instead of early-returning on
201 `0 == 0`.
202 - Webviews created by pages (`window.open`, `target=_blank`) are built inside the
203 delegate — which is why `Delegate` holds a `Weak` to itself and a clone of the
204 `UserContentManager` — parked in `pending_new`, and adopted as tabs by the next
205 `pump`. They are built before anyone told them the theme, so `pump` calls
206 `notify_theme_change` on adoption.
207
208 ### Session restore
209
210 The open-tab set persists across restarts: `src/session.rs` writes
211 `~/.local/state/cce/browser/tabs.tsv` (one `<active-flag>\t<url>` line per tab)
212 and startup restores it, engine-agnostically — the chrome reads tabs back
213 through the shared host surface, so both backends get it for free. Points that
214 are choices, not accidents:
215
216 - **Saves are eager, not on-exit** — `persist_session()` fires on every tab
217 open/close/switch and on navigation (via the Spin-dirty path), so a crash or
218 a compositor-side window close loses nothing. `Session::save` compares
219 against the last serialization and skips no-op writes, which is what keeps
220 the loading-time signal storm off the disk.
221 - **Closing the last tab saves the empty set** before `Message::Quit`, so a
222 deliberately emptied browser starts fresh on the homepage instead of
223 resurrecting what was just closed. Quitting via the window close keeps the
224 tabs (they were never closed).
225 - **A launch argument opens as an extra tab on top of the restored set**; only
226 when there is nothing to restore does it become the single starting tab
227 (then falling back to the homepage, as before).
228 - **`about:blank` tabs are skipped on save** — a "New Tab" is not worth
229 resurrecting.
230 - Restore is **eager**: every saved tab starts loading at launch (one
231 WebProcess each on WPE). Fine at normal tab counts; lazy restore is the
232 upgrade path if someone lives with dozens.
233
234 ## The chrome is hand-rolled
235
236 There are **no `cce-ui` widgets in this app**. The whole utility bar is emitted as
237 `PaintCtx` primitives in `display_list` (`display_list_text()` returns `true`), and
238 every hit test in `handle_mouse_input` re-derives the same rects from the same
239 `bar_rect`/`tab_rect`/`btn_rect`/`url_rect`/`fav_rects` helpers. **Draw and hit-test are two
240 readings of one geometry** — change a rect helper, not one call site.
241
242 ### Favorites are not bookmarks
243
244 Two stores, two meanings. The **star** (`Ctrl+D`, `cce://bookmarks`) is the
245 archive: everything worth finding again, newest first. **Favorites**
246 (`Ctrl+Shift+D`, the right-click menu's "Add to Favorites", or the
247 `favorite` link on a bookmark row; managed at `cce://favorites` /
248 `about:favorites`, `Ctrl+Shift+B`) are the handful of places worth a
249 permanent one-click spot: a **strip of label pills inside the bar**, between
250 the tab row and the controls row. Click loads the favorite in the active tab
251 and folds the bar (a menu pick); middle-click opens it in a new tab and
252 leaves the bar out. Insertion order is strip order; the page reorders
253 (▲/▼), renames (a GET form per row — form submissions reach the `cce:`
254 handler like any other navigation) and removes.
255
256 ### The bookmarks menu
257
258 The controls row's **"B" button** (immediately left of the star) drops the
259 bookmarks menu: the star is *this* page's bookmark, the button beside it is
260 all of them. Three sections — add/remove this page, the saved pages
261 themselves (newest first, the `cce://bookmarks` order), and
262 `Manage Bookmarks (n)` which hands the collection to that page. A row visits
263 in the active tab and folds everything away, middle-click opens it in a new
264 tab and leaves the menu up, and the **remove "x"** on the hovered row prunes
265 in place. It closes on Escape (ahead of the URL bar and the page), on a
266 click anywhere off its plate, and with the bar it hangs from.
267
268 Points that are choices, not accidents:
269
270 - **It snapshots the store when it opens.** A list a pointer is travelling
271 down must not reorder underneath it, so the two edits it offers re-read
272 explicitly (`refresh_bm_menu`) rather than the paint path reading the
273 store every frame.
274 - **It is not gated on an engine backend**, unlike the right-click menu:
275 bookmarks are app state, so both hosts expose `bookmarks()` and the menu
276 works on either.
277 - **`bm_layout()` is the one geometry** draw and hit-test both read — plate,
278 toggle row, visible entry rows, manage row. It hangs off the button
279 (**below** a top bar, **above** a bottom one), right-aligned to it and
280 clamped on screen, and never grows past the space it has: `cap` is how
281 many rows fit and the list **scrolls** past that, wheel included, rather
282 than the plate running off the window.
283 - **An open menu owns the pointer**: clicks, moves and the wheel all stop at
284 it, exactly as the right-click menu already did, so the page behind never
285 sees a click that was meant to dismiss a menu.
286 - Rows are drawn at **full brightness**. Dim means *unavailable* everywhere
287 else in this chrome (the disabled toggle on an internal page says so that
288 way), and hover is the highlight rect's job.
289 - Opening drops URL-bar focus, for the same reason folding does: a field
290 behind a menu must not keep eating keystrokes.
291
292 `Ctrl+B` still opens the `cce://bookmarks` page rather than this menu — the
293 page is the fuller tool, and the menu is a pointer affordance.
294
295 ### Favorites geometry
296
297 Geometry points that are choices: the bar has **no empty row** — with no
298 favorites it is the two-row bar it always was (`bar_h(favorites)`), so
299 `controls_y` is measured from the bar's *bottom* edge rather than counted
300 down from the top. The strip does not scroll or wrap: pills take their
301 label's width up to `FAV_MAX_W`, and `fav_rects` simply stops at the bar's
302 edge, so a too-long strip loses its tail. The chrome keeps a snapshot
303 (`favs`) refreshed with the rest of the page state, which is also how edits
304 made on the `cce://favorites` page — on the way into a navigation — reach
305 the strip. A label defaults to the page title, else the host (`www.`
306 stripped), else the file name; internal pages are refused.
307
308 ### The bar is a circle menu
309
310 The chrome's persistent element is the **DE's corner control** —
311 `cce_ui::widget::plate_dock::draw_corner_dot`, the same 8px plate-border-colored
312 dot a designer pane or the terminal window wears at its top-right — sitting in
313 the **bar's corner nearest the window corner it is anchored to** (`dot_center()`:
314 top-right for a top bar, bottom-right for a bottom one, at the DE inset). A
315 circle menu is the corner of the thing it expands into, so the dot sits where
316 the bar's corner will be, the bar grows out of the dot's own disc, and open,
317 the dot is the bar's corner. It is always drawn and always live: clicking it
318 unfolds the two-row bar, clicking it again folds the bar back. `chrome_open` names the state, `chrome_t` the unfold
319 progress (animated in `tick` over `CHROME_ANIM_S`), and `dot_hover` its hover
320 emphasis, which is a repaint. **Do not decorate the dot** — no glyph, no
321 lines, no ring; it is the DE's control, not a browser icon.
322
323 `chrome_plate()` is the one shape draw and hit-test both read — the bar, or
324 the lerp from the dot's disc up to the bar — and
325 `chrome_hit()` is the chrome's pointer gate (the dot always, the plate while
326 any of it shows). The bar's contents are laid out at their *final* rects and
327 clipped to the growing plate, so the unfold is a reveal, not a re-layout. The
328 row the dot sits on reserves `DOT_COL` at its right end (`dot_col`: the tab
329 row's "+" for a top bar, the controls row's star for a bottom one);
330 `bar_rect` and the rest of the helpers are otherwise unchanged. The plate is
331 drawn through `plate_shaped`, a per-plate corner exponent added to `cce-ui`,
332 easing from circular at the seed to the DE's own squircle as it becomes the
333 bar.
334
335 Menu semantics, all in `handle_mouse_input` / `handle_key_input`:
336
337 - **Open**: click the dot; `Ctrl+L` (then focuses the URL); `Ctrl+T` (a new
338 tab focuses the URL field, which must be on screen). The bookmarks menu
339 is a second layer inside the open bar, and folding takes it with it.
340 - **Fold**: click the dot again; click the page; `Escape` with the URL
341 unfocused (the first Escape in a focused field only drops focus, as
342 before); submitting a URL; picking a tab. Closing a tab does *not* fold —
343 several often go in a row.
344 - Folding drops URL-bar focus (`close_chrome`), so an off-screen field never
345 keeps eating keystrokes. Wheel and pointer moves over the chrome stay off the
346 page, gated by `chrome_hit`, not `bar_rect`.
347
348 ### Key routing
349
350 `handle_key_input` is a three-stage funnel and the order is load-bearing: Ctrl chords
351 that belong to the chrome (tabs, internal pages, bookmark, external-open) fire
352 **regardless of URL-bar focus**; then a focused URL bar swallows everything into
353 `edit_url`; only then does the key reach the page.
354
355 Two page-directed cases are not plain key forwarding:
356
357 - **Ctrl+C/X/V go to the page as `EditingActionEvent`**, not as keystrokes. Servo has
358 no built-in binding for the chords; sent raw they do nothing.
359 - **Modifiers must be passed explicitly.** `KeyboardEvent::from_state_and_key` defaults
360 them to empty, which delivers every chord as a bare character — Ctrl+A typed a
361 literal "a" into a focused textarea instead of selecting it.
362
363 Wheel events pass **winit-signed deltas** (positive = up) with no separate scroll
364 event: the engine hit-tests the wheel, gives the page its `preventDefault` chance, and
365 applies the inverted delta itself.
366
367 ### The wheel eases; the trackpad does not
368
369 A notch used to move the page `LINE_PX` in one step, which is the browser
370 feeling unlike every other cce app. It now goes through
371 `cce_ui::widget::scroll_motion` — the DE's shared model, tuned by
372 `smooth_scroll` / `scroll_ease` in `input.kdl` (this app's domain, then
373 `cce-ui`'s) — so notches glide and several in a row accumulate into one
374 movement instead of a staircase.
375
376 The browser does not own the page's offset, so the model runs as a **virtual**
377 one: `apply` moves its target, `tick` walks the eased position, and
378 `advance_scroll` hands the engine the *difference* since the last frame. WebKit
379 keeps the real position and clamps it at the page's ends, which is why the
380 bounds here are `UNBOUNDED`. The accumulator is rebased to zero whenever the
381 glide settles, and dropped on a tab switch or a real navigation, so deltas
382 aimed at one page never land on the next.
383
384 Points that are choices:
385
386 - **Only a notch eases.** A trackpad's pixel deltas already follow the finger,
387 and the engine runs its own kinetic scrolling off the gesture phases
388 `host.wheel` passes it. Two coast models fighting over one page would be
389 worse than either, so `Finger` and `FingerEnd` keep the direct path.
390 - **The phase is set explicitly before each glide frame** rather than
391 inherited: a stale `FingerEnd` would tell the engine every frame that a
392 gesture had just ended.
393 - **`tick` asks for a rebuild while the glide is in flight**, the same way the
394 chrome's unfold does — that is what keeps the runner's loop turning.
395 - Settings are read once per process (`scroll_settings` is a `OnceLock`), so a
396 change to `input.kdl` needs a restart.
397
398 Measured in a headless shadow at scale 2, one notch: eased it walks
399 92 → 123 → 145 → 159 → … → 190; direct it lands on 190 immediately. Three
400 notches in quick succession land exactly three notches on (190 → 760), so the
401 distance a notch travels is unchanged — only its timing. (The 190 is WebKit's
402 own multiplier on a precise delta; the direct path has always moved that far.)
403
404 ## Account autocomplete (cce-secrets)
405
406 A login field on a page gets a list of the accounts the keyring holds for that
407 site; picking one fills the username and password. There is no cce-secrets
408 *protocol* — that app fronts the freedesktop **Secret Service** (gnome-keyring
409 here) and so does this, reading the same entries: item label as the title,
410 `UserName` and `URL` as attributes. `browser.accounts` (default true) is the
411 one switch; with it off nothing is injected and the keyring is never opened.
412
413 Three files meet: `accounts.rs` (which entries a host earns, and the worker
414 that reads them), `wpe/formwatch.rs` (the page half), and `AcMenu` in
415 `main.rs` (the list itself, drawn at the field like every other menu here).
416 WPE only — the retired Servo backend has no user-script hooks — so the chrome
417 side is `#[cfg(feature = "wpe")]`, while `accounts.rs` is not.
418
419 The security shape is the design, not decoration:
420
421 - **Everything runs in a private script world** (`formwatch::WORLD`). The page
422 cannot see or replace the watcher's helpers, so it cannot hook the moment a
423 credential is filled, and it cannot post on the chrome's message channel to
424 fake a focused field.
425 - **Top frame only.** A password field in a cross-origin iframe gets no
426 suggestions: such a frame cannot report a position in the top document's
427 coordinates anyway, and an embedded frame asking for the embedder's
428 credentials is the attack this must not enable. The reported `location.origin`
429 is checked against the tab's own host on every event, on top of that.
430 - **Matching is narrow** (`Account::matches`): exact host, or a *parent* domain
431 covering its subdomains — never upward, never sideways. An entry with no URL
432 falls back to its title against the site name (`GitHub` → `github.com`), the
433 one guess in here, made only when there is nothing better.
434 - **No password is fetched to build a list.** Listing reads labels, usernames
435 and URLs; the pick is what asks the keyring for one secret, by object path.
436 `accounts::Secret` prints as `Secret(…)` so a derived `Debug` on `Message`
437 cannot spill it into a log.
438 - **The fill is re-checked when it lands.** An unlock prompt can put seconds
439 between the pick and the answer, so `fill_account` drops the credential
440 unless the list is still open, still holds that account, and the tab is
441 still on the host it was opened for.
442 - **Never automatic.** Nothing fills without a pick, nothing submits the form,
443 and a locked collection is skipped rather than unlocked — the browser asking
444 for the keyring password because a page happened to show a login field would
445 be its own phishing lesson. cce-secrets is where unlocking belongs.
446 - The list says so when the page is not https and not loopback
447 (`insecure_origin`): the password would cross the network in the clear, and
448 only the person can decide that is fine.
449
450 Things that were learned the hard way and are easy to undo:
451
452 - **The keyring is read on the first login field, never at launch.** A browser
453 that never sees one never opens the store, which is what keeps this from
454 costing an unlock prompt at login.
455 - **A field can be focused before the index has finished loading** — it always
456 is, on a page that autofocuses. The chrome answers the load by asking the
457 watcher to re-report (`request_form_state` → `RESCAN_JS`); without that
458 nudge the first login form of a session silently gets nothing.
459 - **The engine's dirty flag is not a navigation.** Clearing the list on
460 `dirty` closed it in the same pump that opened it (title and loading
461 transitions set it too). It is keyed on the tab's URL actually changing
462 (`nav_url`), and form events are drained *after* that check so an event
463 arriving with the load survives it.
464 - **A fill must not report itself.** The `input` and `change` events the fill
465 dispatches — which are the point, since frameworks ignore a plain assignment
466 — came back as "the user typed" and re-opened the list, filtered by the name
467 just filled in. The watcher holds a `filling` flag across the fill.
468 - **CSS pixels are the chrome's logical pixels.** `resize` hands WPE the
469 *logical* size and sets the scale separately, so a viewport rect from the
470 page needs no conversion at any output scale (verified at scale 2).
471
472 Testing it needs an isolated keyring, never the real one: `dbus-run-session`
473 plus `gnome-keyring-daemon --unlock --components=secrets`, seeded with
474 `secret-tool`, and the browser launched into that bus with
475 `DBUS_SESSION_BUS_ADDRESS`. A `file:` page will not do — its origin is `null`,
476 so serve the fixture over http on localhost.
477
478 ## `cce://` pages
479
480 `CceProtocol` registers the `cce` scheme with Servo's `ProtocolRegistry`, so
481 `cce://history`, `cce://bookmarks`, `cce://favorites`, `cce://downloads` and
482 `cce://cookies` are **real pages fetched through Servo's network stack** and rendered
483 like any other. That is why every mutating action is an ordinary link
484 (`cce://history/clear`, `cce://bookmarks/remove?url=…`,
485 `cce://favorites/up?url=…`) — no chrome plumbing needed.
486
487 ### Servo leaks a document per load — the biggest live hazard
488
489 Measured 2026-08-27: a page on a 1 s reload loop grows RSS ~0.9 GB per 90 s, linear,
490 never reclaimed. Isolated cleanly — the same page's JS churn *without* the reload is
491 flat, and an animation-heavy real page (cloudflare.com fully loaded) is flat. It is
492 navigation that leaks, not script. This is upstream in Servo and not fixable here.
493
494 In the wild it took the whole machine down: a **Cloudflare interstitial**
495 (`"Just a moment..."`) re-runs itself waiting on a browser-integrity check Servo can
496 never pass, and reached **54 GB RSS in ~6 minutes** — 83% of a 62 GB box, everything
497 stalling on reclaim. Ctrl+Shift+O (hand the page to another browser) is the escape
498 hatch, and the reason it exists.
499
500 **`cce://downloads` is the same hazard in our own code**: it carries
501 `<meta http-equiv="refresh" content="1">` while any transfer is active, so watching a
502 long download leaks at the rate above. Fixing it means live progress without a
503 navigation, and the obvious route is closed — **`fetch()` cannot reach a `cce:` URL**
504 (tried, including with `Access-Control-Allow-Origin: *`; the protocol registry appears
505 to serve top-level navigations only, and the fetch just rejects). A fix needs either a
506 real localhost HTTP endpoint the page can fetch, or progress moved into the chrome.
507 Until then, don't add a self-refreshing `cce:` page, and know this one is live.
508
509 The handler runs on **Servo's fetch threads**, hence the `Arc<Mutex<_>>` stores. It
510 therefore *cannot reach Servo itself*: `cce://cookies/clear` sets an `AtomicBool` that
511 the next `pump` acts on via `site_data_manager()`. Anything else needing engine access
512 from a page has to take the same route.
513
514 History, bookmarks and favorites are TSV under `~/.local/state/cce/browser/`;
515 `sanitize()` strips tabs and newlines because the format has no escaping. All the
516 pages share the `page()` skeleton — restyle there, not per page. A page's own
517 `<style>` goes in through `head_extra`, which lands *before* the skeleton's, so
518 an override has to out-specify it (`.e .w`, not `.w`).
519
520 Clearing cookies is a **confirm-then-act page**, and Ctrl+Shift+Delete opens it rather
521 than clearing outright: sessions persist now, so an accidental chord would sign the
522 user out of everything.
523
524 ## Downloads
525
526 Servo has no download pipeline at all, so a URL that looks downloadable is diverted to
527 a `reqwest` blocking worker that streams it into the download dir. The sniff is
528 **extension-only** (`DOWNLOAD_EXTENSIONS`) — no `Content-Disposition` or content-type
529 handling — so a download URL with no recognizable extension navigates instead.
530
531 It has to happen in **two places**, and that is not redundancy.
532 `WebViewDelegate::request_navigation` fires only for navigations the *content* starts
533 (a link, `location.href`). A URL the **embedder** supplies never reaches it — neither
534 the first tab's, which Servo loads straight from `WebViewBuilder::url`, nor one from
535 the URL bar — so those are sniffed in `ServoHost::take_as_download` instead. Until that
536 existed, `cce-browser https://…/thing.tar.gz` rendered Servo's "Unknown content type
537 (application/octet-stream)" page rather than downloading. A caller that takes a URL as
538 a download must return *without* navigating, which is what stops the two paths from
539 starting the same transfer twice.
540
541 `Download::id` exists because `clear_finished` shifts Vec positions; worker updates
542 must never carry an index across a lock boundary.
543
544 ## Settings and profile state
545
546 `~/.config/cce/cce-browser/config.kdl`, section `browser`, read at startup and re-read
547 in `handle_focus_change` — so edits made in **cce-system-interface's Browser page**
548 (`../cce-system-interface/src/pages/browser.rs`, which owns the writing side) apply on
549 the next switch back. Keep the key names in `settings.rs` and that page in sync;
550 `external-browser` is currently read here with no UI writing it.
551
552 Servo persists per-profile state (cookie jar, auth cache, HSTS) only when given a
553 `config_dir` — without one every launch starts logged out of every site. It lives at
554 `~/.local/state/cce/browser/profile`, forced to `0700` because **the jar is plaintext
555 JSON holding live sessions**.
556
557 Two engine-level settings have sharp edges, both documented at length in `webview.rs`:
558
559 - **CSS Grid ships disabled in Servo** (`layout.grid.enabled`), so every
560 `display: grid` was refused and fell back to block flow. It is turned on explicitly
561 in `Preferences`; other modern-layout gaps are likely the same kind of default.
562 - **Force-dark schedules *two* reloads**, at 400 ms and 2.5 s. User content reaches the
563 script thread as a separate message, and force-dark also flips the reported scheme
564 (it reports *light*, so pages render the light theme the filter then inverts). Either
565 in-flight change can land after a too-eager reload, leaving a page inverted the wrong
566 way with nothing to reload it again. Both deadlines are needed; don't collapse them.
567
568 Servo's own arboard-backed clipboard delegate lands nothing on the clipboard in this
569 embedding (verified by reading the seat's clipboard back), so `CceClipboard` routes
570 through `cce_ui::widget::clipboard` — which also keeps the browser on the same
571 clipboard path as the rest of the DE.
572
573 ## Not implemented yet
574
575 Worth knowing before assuming a bug: no find-in-page, no zoom, no favicons, and no
576 history/URL autocomplete. (The context menu, JS dialogs and HTTP auth landed with the
577 WPE backend and are Servo-only gaps now.) Account autocomplete does not *save* a new
578 login — cce-secrets is where entries are written — and it does not fill inside
579 cross-origin iframes. Ctrl+Shift+O ("hand this page to
580 another browser") is the deliberate escape hatch for pages Servo cannot follow, such as
581 a Cloudflare challenge that never completes.
582
583 Also note `parse_startup_arg` is **not** `parse_url_input` and the difference is
584 tested: the URL bar turns a dotted, space-free word into a domain guess, which would
585 mangle the local file path a launcher is allowed to pass for `%u` into
586 `https:///home/me/page.html`.