git.lucas.co / cce-browser
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`.