web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
CLAUDE.md (60.7K)
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 GitHub,
11 `github.com/lsgalante/cce-browser`, and a post-commit hook pushes each commit there —
12 `https://git.lucas.co/cce-browser.git` is only an hourly mirror of GitHub. **Committing
13 is not publishing**: a commit whose push failed (hook missing, network down) is on no
14 remote at all, so check `origin/master` after committing). Read the workspace-level
15 `../cce-compositor/WORKSPACE.md` first: workspace layout, the `cce-ui` toolkit, config
16 conventions, and the multi-repo rules all live there.
17
18 Nineteen files, ~14.8k lines. The twelve that carry the design:
19
20 | file | what it owns |
21 | --- | --- |
22 | `src/main.rs` | `BrowserApp` — the `cce-ui` `Application`: chrome layout, hit-testing, the URL line editor, key/pointer routing |
23 | `src/instance.rs` | single-instance forwarding: a later launch hands its argument to the running instance's socket and exits |
24 | `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 |
25 | `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 |
26 | `src/pages.rs` | the `cce:` protocol handler and its History / Bookmarks / Favorites stores |
27 | `src/downloads.rs` | the chrome-side download pipeline (Servo has none) |
28 | `src/session.rs` | open-tab persistence: the tab set survives a restart |
29 | `src/settings.rs` | the per-app KDL config |
30 | `src/vi.rs` | vi mode, after qutebrowser: the modes, the bindings and their parser, hint labels, `:` commands, and the page scripts |
31 | `src/accounts.rs` | accounts from cce-secrets: the Secret Service worker, which entries a host earns, saving a new login, and the never-save list |
32 | `src/wpe/ime.rs` | which page text field is open, as WebKit reports it to the input-method context our display hands out — what `display_list` claims for the on-screen keyboard |
33 | `src/wpe/formwatch.rs` | the page half of account autocomplete: the watcher every frame runs (fields, frame-offset relay, fill asks, sign-in capture) and the events it sends |
34 | `src/wpe/damage.rs` | what a frame changed: damage accumulated per view across skipped frames, turned into the regions `pump` reads back |
35 | `src/raindrop/` | bookmark sync with Raindrop.io's Unsorted: the three-way merge (`mod.rs`), the REST client (`api.rs`), the worker and status line (`sync.rs`) — design in RAINDROP-SYNC.md |
36
37 ## Build
38
39 The **default build is the WPE WebKit browser**: seconds to compile, ~14 MB linked
40 against the system `libWPEWebKit` (`pacman -S wpewebkit` is the one prerequisite).
41 That default is deliberate and load-bearing — while WPE was opt-in, a routine
42 featureless rebuild by another session silently reverted the installed browser to
43 Servo within two days. WORKSPACE.md's old "leave cce-browser out of `cce-ui` sweeps"
44 rule was about Servo's build cost and no longer applies to the default build.
45
46 ```sh
47 cargo build --release -p cce-browser # WPE WebKit (default); shared ../target/
48 cargo test --release -p cce-browser # release, or it builds Servo-debug from scratch
49 ccebuild install --no-build cce-browser # install binary + desktop entry
50 ```
51
52 **`--no-default-features --features servo` builds the retired Servo backend**, and
53 *that* is the expensive one: it compiles Servo (more than the rest of the workspace
54 combined, ~175 MB binary). Only pay for it deliberately. A last-known-good Servo
55 binary sits at `~/.local/state/cce/browser/cce-browser-servo-fallback`.
56
57 There is **no `Makefile`** here (most siblings have one) — install goes
58 through `ccebuild` directly. `Cargo.lock` is gitignored in this crate. Running needs a
59 live Wayland session; it will not run headless.
60
61 The engine-specific sections below (frame pipeline, tabs, key routing) describe the
62 **Servo backend** (`src/webview.rs`, feature `servo`); the WPE equivalents live in
63 `src/wpe/` and are documented in WPE-PORT.md. `servo = "0.4"` comes from crates.io,
64 not a git pin. Servo's embedding API churns
65 hard between releases, so when a version bump breaks the build, expect the delegate
66 trait, the input-event constructors, and `Preferences`/`Opts` to be where it broke.
67
68 ## Single instance
69
70 An external open (the desktop entry's `%u`) spawns a fresh process per link.
71 `src/instance.rs` turns that into a tab: `main()` tries
72 `/tmp/cce-browser-<WAYLAND_DISPLAY>.sock` (the standard `cce_ui::ipc`
73 convention; display keying isolates shadow sessions) before any engine or
74 Wayland work, forwards `open <arg>` / `new-tab` and exits on success, or binds
75 the socket and becomes the instance. The desktop entry's `Exec` is
76 **`cce-browser-open`** (`src/bin/open.rs`), a forwarder that links only libc —
77 the full binary spends ~20ms loading libWPEWebKit before `main()` runs, the
78 slim bin forwards in ~4ms — and execs `cce-browser` when nothing answers. It
79 deliberately duplicates the tiny client protocol rather than import anything;
80 keep it, `instance.rs`, and the socket-path convention in agreement. The listener thread pushes
81 `Message::OpenExternal` into calloop; `update()` parses the relayed argument
82 with `parse_startup_arg` — it *is* a launch argument, so the URL bar's
83 domain-guess parsing stays wrong for it — and a forwarded relative file path is
84 canonicalized on the *sending* side, whose cwd it is relative to. Beyond
85 tidiness this guards the profile dir: two engines must not share the plaintext
86 cookie jar. There is deliberately no `--new-window` yet. On every forwarded
87 open the app asks the compositor to `focus-window cce-browser` over the
88 control socket — focus pans the camera to the window, which is what makes a
89 forwarded link *visible*; without it the tab opens in a window parked
90 off-camera and the click looks inert (that shipped for half a day). The
91 compositor's xdg-activation is not the route: it deliberately answers with an
92 attention notification, not focus.
93
94 The other half of click-to-tab latency is inside WebKit: creating a webview
95 and spawning its WebProcess is ~200ms, so the WPE host keeps a hidden **spare
96 webview** prewarmed on about:blank and `open_tab` adopts it (see the `spare`
97 field in `src/wpe/host.rs`). Measured end to end: a link is a live tab in
98 ~65ms (internal page) / ~120ms (example.com, warm) against ~250/~400ms
99 without. WPE's platform API has no
100 `webkit_web_context_prewarm_spare_web_process` (the GTK port's answer), which
101 is why the spare is hand-rolled.
102
103 ## The frame pipeline
104
105 Servo renders into a **`SoftwareRenderingContext`** (CPU, no GPU handoff), one context
106 shared by every tab. `paint_active()` paints the active webview into it and calls
107 `read_to_image` — **without `present()`**, deliberately, because presenting would
108 release the buffer this needs to read. The pixels upload via `cce_ui::vk::upload_rgba`
109 and the page draws in `display_list` as a single full-bleed quad.
110
111 Everything is on the main thread. Servo's internal threads wake calloop through
112 `Waker` → `Message::Spin` → `ServoHost::pump()`, which spins Servo's loop, drains
113 delegate signals, and repaints only if the *active* tab flagged a frame.
114
115 Two consequences worth holding onto:
116
117 - **Registry images leak unless freed.** Every `image` replacement and every
118 `close_tab` calls `cce_ui::vk::free_image` on the old id. New code that swaps a tab's
119 frame must do the same.
120 - **An idle page produces no frames**, so nothing turns the loop on its own. Anything
121 time-based (see the force-dark reload deadlines) has to spawn a thread that sends
122 `Message::Spin` when the deadline passes, or it simply never fires.
123
124 ## The frame pipeline on WPE
125
126 WebKit renders into a **mappable SHM buffer** (`toplevel_formats` asks for
127 one; DMABuf import is still the Phase 2 in WPE-PORT.md), `read_shm` copies it
128 out, and the pixels are drawn as one full-bleed quad. What that path costs is
129 worth knowing, because it is paid on **every frame of every scroll** and this
130 display is 3840x2400 — 35 MB a frame fullscreen.
131
132 Three things it deliberately does *not* do any more, each measured at that
133 size before it went:
134
135 - **No CPU swizzle.** `WPE_PIXEL_FORMAT_ARGB8888` is BGRA in memory and is
136 handed over as `PixelFormat::Bgra`; the sampler reads either channel order
137 at no cost. Rearranging the bytes cost **7.4 ms a frame**.
138 - **No per-frame allocation.** The destination comes from
139 `cce_ui::vk::recycle_buffer`, and the sink refills the *superseded* frame's
140 buffer rather than dropping it — when the engine outruns `pump`, which is
141 exactly when frames are being thrown away, allocating a new buffer each time
142 would be the most expensive possible way to discard work. A fresh Vec per
143 frame was **4.5 ms**, nearly all zeroing and page faults.
144 - **No copy for `sample_pixel`.** It used to clone the whole frame to answer a
145 three-byte question (only `examples/wpe_dark.rs` asks); `last_pixel` keeps
146 the three bytes instead. That clone was **7 ms a frame**.
147
148 And on the GPU side `pump` calls `update_pixels` when the tab already has an
149 image of the same size, so the frame replaces the contents of one texture
150 instead of creating an image and freeing last frame's — that free took
151 `device_wait_idle`, once per frame. Only a resize (or a tab's first frame)
152 takes the create path.
153
154 What is left per frame: one memcpy out of SHM (whole-buffer when the stride is
155 tight, per row otherwise), one memcpy into the shared staging buffer, and the
156 transfer. The remaining `queue_wait_idle` inside the toolkit's update is
157 explained there. **Do not reintroduce a `Vec` allocation, a swizzle, or a
158 second copy on this path without measuring** — the numbers above are what each
159 one costs.
160
161 ### Only the damage is read
162
163 Every webview runs with WebKit's `PropagateDamagingInformation` feature on
164 (`FEATURES` in `host.rs`), so each frame says what it repainted. The sink keeps
165 that per view — *including frames handed back unread*, whose changes the next
166 read still has to carry — and `read_frame` copies only those rectangles
167 (`src/wpe/damage.rs`) and hands them to `cce_ui::vk::update_pixel_regions`,
168 which writes them into the tab's existing image in one submission. A whole
169 frame is read when the tab has no image at that size yet, when a frame said
170 nothing (no rectangles = unknown), or when the damage covers half the frame or
171 more. Measured in a shadow: an overlay scrollbar fading costs 0.27 MB a frame
172 instead of 15 MB, and on Muji with its sliding banner the browser's own CPU
173 went from 15% to 10% of a core.
174
175 A frame belongs to **the tab that drew it** (`index` in `pump`), not to the
176 active tab; a view no tab owns (the spare) is released unread. Before this the
177 newest frame from any view was uploaded as the active tab's picture.
178
179 Two switches: `CCE_BROWSER_FULL_FRAMES=1` reads whole frames as before (the
180 escape hatch if a page is ever drawn stale), and `CCE_BROWSER_DAMAGE_CHECK=1`
181 keeps a CPU copy of each tab patched region by region and compares it with
182 the whole frame every time, logging any pixel that disagrees. It was exact on
183 every read through loading, idling, scrolling, a resize and Muji. Run it after
184 touching any of this.
185
186 WebKit also gets `HiddenPageCSSAnimationSuspension`: a background tab's CSS
187 animations stop, as its `requestAnimationFrame` already does. Its timers still
188 run — WebKit has no hidden-page timer throttling here — which is most of what a
189 heavy page costs in the background.
190
191 ### The engine is paced to draws
192
193 Neither half of the buffer protocol is said in `render_buffer`.
194 `wpe_view_buffer_released` — *the memory is yours again* — is said once the
195 pixels are copied out, in `pump`. `wpe_view_buffer_rendered` — *displayed* —
196 is the engine's pacing: until it is said, WebKit composites nothing new for
197 that view, and its main thread runs no rendering update (rAF, style, layout,
198 paint all wait on the composite). WebKit's own Wayland backend says it from
199 the compositor's frame callback. Here it is said **when the frame is read**,
200 and a frame is read only once the chrome has drawn the one before it
201 (`pending_draw`, cleared by `frame_drawn` from `display_list`). So the page
202 runs at the rate the window is drawn: one frame on screen, one finished and
203 waiting, nothing composited beyond that. (Saying neither stalls the engine
204 after exactly one frame.)
205
206 Points that are choices:
207
208 - **Not at the draw.** Saying `rendered` only once the frame was drawn left
209 the engine no overlap with the chrome, and Muji's banner, animating at
210 60 fps in a visible window, dropped to 30.
211 - **A frame held behind an undrawn one has to be fetched.** Its view is waiting
212 on `rendered`, so it makes no noise that would turn the loop; `frame_drawn`
213 returns whether one is waiting, and `display_list` sends `Spin` for it.
214 - **Every buffer gets both halves.** One superseded unread (another view's
215 frame arrived), released with `release_held`, or unreadable is answered
216 `rendered` too on the spot — a view never answered never composites again.
217 - **Headless callers must call `frame_drawn`** after `pump` (the examples do),
218 or the page stops after its second frame.
219
220 Until 2026-10-05 `rendered` was said at once in `render_buffer`, and only the
221 *readback* was paced. Measured in a scale-2 shadow with Muji in the active tab:
222 display off, the engine still composited 90-105 frames a second of which 4
223 were read, the browser and its web processes using 37-40% of a core; paced,
224 it composites the 4 it shows and uses 13%. Visible, 50 frames/s shown at
225 45-48% against 46 at 58-63% before. With a plain tall page and the display
226 off, the overlay scrollbar's fade now costs 4 frames/s instead of 60 for its
227 ~5 seconds. (The fade is WebKit's: timer-driven, 2s delay then 3s, and it
228 ends on its own drawn or not. A report of it looping forever while undrawn
229 did not reproduce.)
230
231 `CCE_BROWSER_FRAME_DEBUG=1` logs frames produced against frames read once a
232 second; paced, the two match, and the rate is the draw rate.
233
234 ## Tabs
235
236 One `WebView` per tab, all sharing the single rendering context; only the active one
237 is shown, focused, sized and painted (servoshell's model). Each `Tab` keeps its last
238 frame, so switching shows content instantly while the resize refreshes it.
239
240 - The delegate cannot touch `ServoHost` (it is held by Servo), so it records into
241 `HostShared` — a `dirty` flag plus a per-`WebViewId` `TabSignals` map — which `pump`
242 polls and folds into the `Tab` structs. New page state goes in `TabSignals`, not in
243 a delegate callback that tries to mutate the host.
244 - `TabSignals::loading` is `Option<bool>` on purpose: a defaulted `false` would read as
245 "finished loading" and swallow the true→false transition that history recording
246 keys on.
247 - `active: usize::MAX` is a **sentinel**, set in `new()` and again in `close_tab`, so
248 `activate()` does the full show/focus/resize dance instead of early-returning on
249 `0 == 0`.
250 - Webviews created by pages (`window.open`, `target=_blank`) are built inside the
251 delegate — which is why `Delegate` holds a `Weak` to itself and a clone of the
252 `UserContentManager` — parked in `pending_new`, and adopted as tabs by the next
253 `pump`. They are built before anyone told them the theme, so `pump` calls
254 `notify_theme_change` on adoption.
255
256 ### Session restore
257
258 The open-tab set persists across restarts: `src/session.rs` writes
259 `~/.local/state/cce/browser/tabs.tsv` (one `<active-flag>\t<url>` line per tab)
260 and startup restores it, engine-agnostically — the chrome reads tabs back
261 through the shared host surface, so both backends get it for free. Points that
262 are choices, not accidents:
263
264 - **Saves are eager, not on-exit** — `persist_session()` fires on every tab
265 open/close/switch and on navigation (via the Spin-dirty path), so a crash or
266 a compositor-side window close loses nothing. `Session::save` compares
267 against the last serialization and skips no-op writes, which is what keeps
268 the loading-time signal storm off the disk.
269 - **Closing the last tab saves the empty set** before `Message::Quit`, so a
270 deliberately emptied browser starts fresh on the homepage instead of
271 resurrecting what was just closed. Quitting via the window close keeps the
272 tabs (they were never closed).
273 - **A launch argument opens as an extra tab on top of the restored set**; only
274 when there is nothing to restore does it become the single starting tab
275 (then falling back to the homepage, as before).
276 - **`about:blank` tabs are skipped on save** — a "New Tab" is not worth
277 resurrecting.
278 - Restore is **eager**: every saved tab starts loading at launch (one
279 WebProcess each on WPE). Fine at normal tab counts; lazy restore is the
280 upgrade path if someone lives with dozens.
281
282 ### A dead or hung page
283
284 A tab's WebProcess can die (crash, memory limit) or hang (a deadlock inside
285 WebKit/Mesa froze one for good on 2026-10-05: main thread on a driver lock,
286 zero CPU, the UI process perfectly idle — it looked like the browser was
287 stuck). `src/wpe/host.rs` handles both; `examples/wpe_crash.rs` proves them
288 against the real engine. Points that are choices:
289
290 - **A dead process gets an error page under the dead page's URL**
291 (`web-process-terminated` → `terminated_page`, loaded as alternate HTML in
292 `pump`, not inside the signal). The URL bar, the saved session and Reload
293 all still mean the real page. The real URL is passed as the *base* URI too:
294 without it the error page is `about:blank` to itself and its Reload link
295 goes nowhere (refused outright for a `file:` page).
296 - **A hang raises a modal** ("This page isn't responding": Stop page / Wait).
297 Stop kills the process with `webkit_web_view_terminate_web_process`, which
298 lands in the error page above. Wait (or Escape) is remembered per tab until
299 the page answers again. **Enter does not stop**: the question can appear
300 mid-typing, and an Enter aimed at the page must not destroy what was typed.
301 - **WebKit's own responsiveness timer misses the commonest hang.** Pointer
302 events are queued behind an unacknowledged one, and a *move* — which always
303 precedes a click — does not start the timer, so the clicks behind it are
304 never even sent. Keys and the wheel are caught; move-then-click was not.
305 Every page press therefore also sends a no-op script in a private world
306 (`ping`), and a ping unanswered for `HANG_GRACE` (3s) is a hang. A one-shot
307 GLib timeout wakes the loop when the grace runs out, since a hung page
308 sends nothing that would. A pending `alert()` or auth challenge is never a
309 hang — the page is blocked on *us* — and answering one forgets the ping
310 that was waiting behind it.
311 - **A deadlock is recovered without asking** (`watch_deadlock`). A hung page
312 and a spinning script look alike from here; the difference is CPU: the
313 2026-10-05 deadlock sat at zero, a script burns a core. WebKit says nothing
314 about which process serves which tab, so every WPEWebProcess under the
315 browser is sampled from `/proc`, and only if *all* of them stay under 5% of
316 a core for `DEADLOCK_WATCH` (5s) is the process stopped and the page loaded
317 again — a plain load, so a form post is not resubmitted. Busy, waited on
318 ("Wait"), or already recovered within `AUTO_RECOVER_GAP` (2 min) is left to
319 the prompt, so a page that deadlocks on every load cannot reload-loop.
320 - **The deadlock itself is a Mesa iris bug, still on Mesa `main`**: a lock-order
321 inversion between the aux-map mutex and `bufmgr->lock`, hit by WebKit's two
322 Skia GPU painting threads allocating textures at once (one adds an aux
323 mapping and needs a new table page; the other reuses a cached BO and unmaps
324 its old aux range). `main()` therefore sets `INTEL_DEBUG=noccs` before
325 anything starts (`disable_intel_ccs`): without CCS the aux map is never
326 used. Page processes inherit it through bubblewrap. Measured in a scale-2
327 shadow scrolling 24 large images: no difference in frame rate or CPU.
328 `CCE_BROWSER_CCS=1` turns compression back on.
329
330 ## The chrome is hand-rolled
331
332 There are **no `cce-ui` widgets in this app**. The whole utility bar is emitted as
333 `PaintCtx` primitives in `display_list` (`display_list_text()` returns `true`), and
334 every hit test in `handle_mouse_input` re-derives the same rects from the same
335 `bar_rect`/`tab_rect`/`btn_rect`/`url_rect`/`fav_rects` helpers. **Draw and hit-test are two
336 readings of one geometry** — change a rect helper, not one call site.
337
338 **Every symbol the chrome draws is a cce-icons glyph** through `BrowserApp::glyph`
339 (`PaintCtx::icon`, tinted like text): Back/Forward/Reload are `arrow-left`/`arrow-right`/
340 `refresh`, the tab close and a bookmark row's remove `x`, new tab `plus`, the star `star`
341 (accent-lit when saved), the bookmarks button `bookmarks`, a select list's current option
342 `check`. Never a character standing in for one (the bar was `<` `>` `R` `*` `B`); the
343 fallback, drawn only with the icon set missing, is a plain word.
344
345 ### Favorites are not bookmarks
346
347 Two stores, two meanings. The **star** (`Ctrl+D`, `cce://bookmarks`) is the
348 archive: everything worth finding again, newest first. **Favorites**
349 (`Ctrl+Shift+D`, the right-click menu's "Add to Favorites", or the
350 `favorite` link on a bookmark row; managed at `cce://favorites` /
351 `about:favorites`, `Ctrl+Shift+B`) are the handful of places worth a
352 permanent one-click spot: a **strip of label pills inside the bar**, between
353 the tab row and the controls row. Click loads the favorite in the active tab
354 and folds the bar (a menu pick); middle-click opens it in a background tab
355 and leaves the bar out. Insertion order is strip order; the page reorders
356 (cce-icons' arrow-up/arrow-down, inlined as SVG in `pages.rs`), renames (a GET form per row — form submissions reach the `cce:`
357 handler like any other navigation) and removes.
358
359 ### The bookmarks menu
360
361 The controls row's **bookmarks button** (immediately left of the star) drops the
362 bookmarks menu: the star is *this* page's bookmark, the button beside it is
363 all of them. A **search field** on top, then three sections — add/remove
364 this page, the saved pages themselves (newest first, the `cce://bookmarks`
365 order), and `Manage Bookmarks (n)` which hands the collection to that page. A row visits
366 in the active tab and folds everything away, middle-click opens it in a
367 background tab and leaves the menu up, and the **remove glyph** (`x`) on the hovered row prunes
368 in place. It closes on Escape (ahead of the URL bar and the page), on a
369 click anywhere off its plate, and with the bar it hangs from.
370
371 Points that are choices, not accidents:
372
373 - **It snapshots the store when it opens.** A list a pointer is travelling
374 down must not reorder underneath it, so the two edits it offers re-read
375 explicitly (`refresh_bm_menu`) rather than the paint path reading the
376 store every frame.
377 - **It is not gated on an engine backend**, unlike the right-click menu:
378 bookmarks are app state, so both hosts expose `bookmarks()` and the menu
379 works on either.
380 - **`bm_layout()` is the one geometry** draw and hit-test both read — plate,
381 toggle row, visible entry rows, manage row. It hangs off the button
382 (**below** a top bar, **above** a bottom one), right-aligned to it and
383 clamped on screen, and never grows past the space it has: `cap` is how
384 many rows fit and the list **scrolls** past that, wheel included, rather
385 than the plate running off the window.
386 - **An open menu owns the pointer**: clicks, moves and the wheel all stop at
387 it, exactly as the right-click menu already did, so the page behind never
388 sees a click that was meant to dismiss a menu.
389 - Rows are drawn at **full brightness**. Dim means *unavailable* everywhere
390 else in this chrome (the disabled toggle on an internal page says so that
391 way), and hover is the highlight rect's job.
392 - Opening drops URL-bar focus, for the same reason folding does: a field
393 behind a menu must not keep eating keystrokes.
394 - **The search field has the keyboard while the menu is open** (a
395 `LineEdit`, so caret, selection, clipboard and undo come with it). Every
396 word typed must appear in a bookmark's title or address, ignoring case;
397 Up/Down move a selected row through the matches (`BmMenu::step`: it wraps
398 at the ends and scrolls to stay in view, as the account list does, and is
399 highlighted like a hovered row); Enter visits the selected row; Escape
400 still closes the menu; the chrome's Ctrl chords still fire first, as they
401 do over a focused URL bar. A query edit — a key, a paste, an undo —
402 re-filters (`BmMenu::filter`) and takes the list and the selection back to
403 the first match. `all` is the snapshot, `items` what the query
404 lets through, and an `Entry` index points into `items`.
405 - **The plate is sized for the whole collection, not the matches.** Above a
406 bottom bar a plate that shrank as the query narrowed would slide its
407 search field out from under the pointer mid-typing (it did, in the first
408 cut); unmatched slots are left empty instead. `Manage Bookmarks (n)`
409 counts the whole collection for the same reason it exists.
410
411 `Ctrl+B` still opens the `cce://bookmarks` page rather than this menu — the
412 page is the fuller tool, and the menu is a pointer affordance.
413
414 ### Favorites geometry
415
416 Geometry points that are choices: the bar has **no empty row** — with no
417 favorites it is the two-row bar it always was (`bar_h(favorites)`), so
418 `controls_y` is measured from the bar's *bottom* edge rather than counted
419 down from the top. The strip does not scroll or wrap: pills take their
420 label's width up to `FAV_MAX_W`, and `fav_rects` simply stops at the bar's
421 edge, so a too-long strip loses its tail. The chrome keeps a snapshot
422 (`favs`) refreshed with the rest of the page state, which is also how edits
423 made on the `cce://favorites` page — on the way into a navigation — reach
424 the strip. A label defaults to the page title, else the host (`www.`
425 stripped), else the file name; internal pages are refused.
426
427 ### The bar is a circle menu
428
429 The chrome's persistent element is a **corner control** — in the place of the
430 DE's dot (`cce_ui::widget::plate_dock::draw_corner_dot`, on a designer pane or
431 the terminal window), but drawn as a **circular plate**: the bar's own material
432 (`BAR_FILL`) and rolled bevel, `plate_shaped` with corner exponent 2 — sitting
433 in the **bar's corner nearest
434 the window corner it is anchored to** (`dot_center()`: top-right for a top bar,
435 bottom-right for a bottom one). It is drawn here at **1.75x the DE's size**
436 (`DOT_R`, 14px radius against `plate_dock::CORNER_R`'s 8, with `DOT_INSET`
437 keeping the DE's margin to the plate edge): folded, it is the whole chrome, and
438 the pane-corner size was too small to find. The size lives in this crate, not
439 in `cce-ui` — the shared constant is every other app's dot too — so the drawing
440 and the hit circle are the browser's own (`dot_hit`), same disc. Being a plate
441 rather than a flat plate-border disc (it was one until 2026-10-05) is what makes
442 it read as the bar in miniature: folded, it is exactly the seed shape the bar
443 unfolds from; open, it is a raised plate on the bar's corner. A
444 circle menu is the corner of the thing it expands into, so the dot sits where
445 the bar's corner will be, the bar grows out of the dot's own disc, and open,
446 the dot is the bar's corner. It is always drawn and always live: clicking it
447 unfolds the two-row bar, clicking it again folds the bar back. `chrome_open` names the state, `chrome_t` the unfold
448 progress (animated in `tick` over `CHROME_ANIM_S`, or landed in one frame
449 when the DE's animations switch, `cce_ui::motion::enabled`, is off), and
450 `dot_hover` its hover emphasis, which is a repaint. **Do not decorate the dot** — no glyph, no
451 lines, no ring; it is a plate, not a browser icon.
452
453 `chrome_plate()` is the one shape draw and hit-test both read — the bar, or
454 the lerp from the dot's disc up to the bar — and
455 `chrome_hit()` is the chrome's pointer gate (the dot always, the plate while
456 any of it shows). The bar's contents are laid out at their *final* rects and
457 clipped to the growing plate, so the unfold is a reveal, not a re-layout. The
458 row the dot sits on reserves `DOT_COL` at its right end (`dot_col`: the tab
459 row's new-tab plus for a top bar, the controls row's star for a bottom one);
460 `bar_rect` and the rest of the helpers are otherwise unchanged. The plate is
461 drawn through `plate_shaped`, a per-plate corner exponent added to `cce-ui`,
462 easing from circular at the seed to the DE's own squircle as it becomes the
463 bar.
464
465 Menu semantics, all in `handle_mouse_input` / `handle_key_input`:
466
467 - **Open**: click the dot; `Ctrl+L` (then focuses the URL); `Ctrl+T` (a new
468 tab focuses the URL field, which must be on screen). The bookmarks menu
469 is a second layer inside the open bar, and folding takes it with it.
470 - **Fold**: click the dot again; click the page; `Escape` with the URL
471 unfocused (the first Escape in a focused field only drops focus, as
472 before); submitting a URL; picking a tab. Closing a tab does *not* fold —
473 several often go in a row.
474 - **Peek**: a tab opening in the background (`open_background_tab`) unfolds a
475 folded bar for `CHROME_PEEK` (1.5s), so the new tab is seen landing in the
476 strip, then folds it again. `chrome_peek` is the deadline; a thread sends
477 `Spin` when it passes (an idle page turns no loop) and `settle_peek` folds.
478 Any press on the bar, or any explicit open, makes it the person's and it
479 stays; a pointer *brought onto* it holds the fold off; a page click does
480 not fold a peek, so a run of middle-clicked links keeps it out instead of
481 flapping it. A bar the person already had open is left alone.
482 - Folding drops URL-bar focus (`close_chrome`), so an off-screen field never
483 keeps eating keystrokes. Wheel and pointer moves over the chrome stay off the
484 page, gated by `chrome_hit`, not `bar_rect`.
485
486 ### Select lists
487
488 **WPE draws no `<select>` popup of its own.** A click on a select raises
489 `show-option-menu` with the options and the select's box, and a select
490 nobody answers simply never opens — which is how every dropdown on every
491 page was dead until 2026-10-05. `on_show_option_menu` (`src/wpe/host.rs`)
492 holds the `WebKitOptionMenu` and hands the chrome an `OptionMenuInfo`;
493 `OptMenu` in `main.rs` draws the list at the select and answers with
494 `pick_option` (activate + close: the value changes and `change` fires) or
495 `close_option_menu`. Points that are choices:
496
497 - **`select_item` is never called.** WebKit's `close` activates whatever is
498 selected, so moving WebKit's selection with the highlight would make
499 Escape commit the row it was on. The highlight is the chrome's alone.
500 - **The page can close it too** (the select is removed, the page
501 navigates): the menu's own `close` signal drops the held menu, and the
502 next `Spin` sees `option_menu_open()` false and takes the list down.
503 - **The anchor is logical pixels** — the chrome's — at any output scale
504 (verified at 2), like the account list's field rects.
505 - **`opt_layout()` is the one geometry**: under the select, or over it when
506 there is more room above, never past the window; a longer list scrolls
507 (wheel, arrows, paging), with a thumb. Wheel travel accumulates in
508 `wheel_rest` — a trackpad's few pixels an event, rounded one at a time,
509 never moved it.
510 - It **owns the pointer and the keyboard** while up, like the right-click
511 menu: a click off it closes it and goes no further (so clicking the
512 select again folds it), and no key reaches the page or the chrome's
513 chords. Up/Down/PageUp/PageDown/Home/End skip disabled options and
514 optgroup headings; Enter (or Space, outside type-to-find) picks; Escape
515 and Tab close; letters find (`find_typed`: "1","0" finds 10, a repeated
516 letter cycles).
517 - A tab switch, a tab close, losing window focus and a resize all close it.
518
519 `examples/wpe_options.rs` proves the engine side against the real engine.
520
521 ### Middle-click is a background tab
522
523 Every "open this in a new tab" that is not the person asking for a fresh tab
524 opens **behind** the page: a middle-clicked link in a page, a middle-clicked
525 favorite or bookmark-menu row, and the context menu's "Open Link in New Tab".
526 The active tab, its focus and the URL bar stay as they were, and the bar
527 peeks (above). `Ctrl+T`, the new-tab plus and a forwarded external open still open in
528 front — those are asks to *go* somewhere.
529
530 A page link reaches the chrome through WebKit's `decide-policy`
531 (`on_decide_policy`): a link-click navigation — or new-window one, for
532 `target=_blank` — carrying mouse button 2 is ignored and its URL queued
533 (`take_background_opens`, drained in the `Spin` handler). Everything else gets
534 WebKit's default. A background webview is left unmapped and invisible, the
535 state a switched-away tab is in. `examples/wpe_middle.rs` proves the engine
536 side (both link kinds diverted, left-click undisturbed, the tab loading
537 behind the page). On the retired Servo backend `open_background_tab` is an
538 open followed by switching back, and page middle-clicks are not caught.
539
540 ### Key routing
541
542 `handle_key_input` is a three-stage funnel and the order is load-bearing: Ctrl chords
543 that belong to the chrome (tabs, internal pages, bookmark, external-open) fire
544 **regardless of URL-bar focus**; then a focused URL bar swallows everything into
545 `edit_url`; only then does the key reach the page.
546
547 Two page-directed cases are not plain key forwarding:
548
549 - **Ctrl+C/X/V go to the page as `EditingActionEvent`**, not as keystrokes. Servo has
550 no built-in binding for the chords; sent raw they do nothing.
551 - **Modifiers must be passed explicitly.** `KeyboardEvent::from_state_and_key` defaults
552 them to empty, which delivers every chord as a bare character — Ctrl+A typed a
553 literal "a" into a focused textarea instead of selecting it.
554
555 Wheel events pass **winit-signed deltas** (positive = up) with no separate scroll
556 event: the engine hit-tests the wheel, gives the page its `preventDefault` chance, and
557 applies the inverted delta itself.
558
559 ### An input method composes into the chrome's fields
560
561 The URL bar, the bookmarks search, the vi command line and a dialog's fields
562 are each a cce-ui `LineEdit`, and an input method (fcitx5, IBus) composes into
563 whichever has the keyboard (`keyboard_field`: a dialog's focused field, else
564 the open vi command line, else the open bookmarks search, else a focused URL
565 bar — `focused_edit` is the same answer). Three
566 things make that work, all against cce-ui's `ime` model:
567
568 - **Before each frame `sync_ime` hands the composition over.** The field with
569 the keyboard takes it up (`LineEdit::sync_ime`); the one that just lost the
570 keyboard drops what it showed (`drop_composition`, which cancels it in the
571 input method too). A field that went away with the keyboard — a dialog
572 answered, the menu closed — cannot drop anything, so a composition still up
573 at the handover is cancelled there: the new field has not taken it yet.
574 - **A field draws `display()`, never its text**: the composition spliced in at
575 the caret (bullets in a password field), underlined, with the caret where
576 the input method has its cursor. `FieldMarks::of` measures all of it on the
577 run `paint_field` draws, and a press is hit-tested on the same string and
578 carried back with `text_index`. The composition is never in `text`, so
579 nothing that reads a field — navigation, the bookmark filter, the dialog's
580 answer — sees it until it is committed, when it arrives as typed keys.
581 - **The field with the keyboard reports its caret as it paints**
582 (`ime::report_caret`): that is where the candidate window opens, and what
583 tells the shell text is wanted at all. A folded bar paints no URL field, so
584 it asks for nothing.
585
586 `a_composition_is_drawn_at_the_caret_and_never_held` is the test.
587
588 ### A page's own fields claim text input too
589
590 A focused field in the page — `<input>`, `<textarea>`, `contenteditable`, in
591 any frame — is claimed from `display_list` as well
592 (`cce_ui::text_input::claim`, before the chrome draws, so an open chrome field
593 claims last and wins). That is what raises the compositor's on-screen keyboard
594 (cce-compositor `osk.rs`) when the field was tapped. WebKit itself says which
595 field is open: the display hands it our own input-method context
596 (`create_input_method_context`, `src/wpe/ime.rs`), whose `focus_in` /
597 `focus_out` / `set_cursor_area` record the field and its caret per view. No
598 script is injected, and nothing else of the context is overridden, so keys
599 still reach the page unfiltered. Points that are choices:
600
601 - **A press into the page holds back the toolkit's re-announcement**
602 (`defer_page_press`). cce-ui announces an open field again after every press
603 so a tap on an already-focused field raises the board — but it would do so
604 before WebKit has seen the press, so a tap on a link or beside the field
605 (or anywhere, on a page that autofocused its search box) would flash the
606 board up. The browser takes the announcement back and makes it
607 `PAGE_PRESS_SETTLE` (150 ms) later only if the field is still open. A tap
608 that moves the caret is announced at once anyway, as a moved caret.
609 - **Not gated on `window_focused`**: the toolkit only enables text input while
610 the compositor gives the surface text-input focus, and `window_focused`
611 follows `wl_keyboard`, which a seat with no keyboard device (a headless
612 shadow) never enters.
613 - **`inputmode="none"` comes from a watcher, not WebKit.** WebKit has a hint
614 for it (`INHIBIT_OSK`, still honoured) but 2.52 sends only `SPELLCHECK`. So
615 `WATCH_JS` runs in every frame in a private world (`cce-ime`, channel
616 `cceIme`) and reports `none`/`text` on each `focusin`, and a field WebKit
617 has just opened is not claimed until that report is in (they land within
618 ms of each other, either order; a report up to `REPORT_EARLY` before the
619 `focus_in` counts) or `REPORT_WAIT` (100 ms) has passed. Claimed at once,
620 the board would be up before the page said it wants none. Moving between
621 fields sends no `focus_in`, only a new report, which applies at once.
622 The watcher speaks only while `document.visibilityState` is visible —
623 that is what keeps background tabs off the shared channel — never
624 `hasFocus()`, which also needs the window to hold the keyboard and is
625 false all through a headless shadow (no keyboard device).
626 - **A composition is not shown in the page.** With an input method running,
627 a committed string should arrive as typed keys, as it does for the chrome
628 (not yet tried with fcitx5/IBus); the preedit is not drawn (WebKit would take it through the same context's
629 `get_preedit_string` and its signals, which nothing emits yet).
630
631 Verified in shadows at scale 1 and 2 with `ctl touch tap`: a tapped field
632 spawns `cce-keyboard show`, a tap or click elsewhere `hide`, a pointer click
633 shows nothing, a tap away from a mouse-focused field shows nothing, and a
634 tap on the field that already has focus shows the board; an
635 `inputmode="none"` field — top-level or in a frame, tapped fresh, tapped
636 again, or moved to from an open field — shows nothing (or hides the board),
637 and moving from it to a normal field shows it.
638
639 ### The wheel eases; the trackpad does not
640
641 A notch used to move the page `LINE_PX` in one step, which is the browser
642 feeling unlike every other cce app. It now goes through
643 `cce_ui::widget::scroll_motion` — the DE's shared model, tuned by
644 `smooth_scroll` / `scroll_ease` in `input.kdl` (this app's domain, then
645 `cce-ui`'s) — so notches glide and several in a row accumulate into one
646 movement instead of a staircase.
647
648 The browser does not own the page's offset, so the model runs as a **virtual**
649 one: `apply` moves its target, `tick` walks the eased position, and
650 `advance_scroll` hands the engine the *difference* since the last frame. WebKit
651 keeps the real position and clamps it at the page's ends, which is why the
652 bounds here are `UNBOUNDED`. The accumulator is rebased to zero whenever the
653 glide settles, and dropped on a tab switch or a real navigation, so deltas
654 aimed at one page never land on the next.
655
656 Points that are choices:
657
658 - **Only a notch eases.** A trackpad's pixel deltas already follow the finger,
659 and the engine runs its own kinetic scrolling off the gesture phases
660 `host.wheel` passes it. Two coast models fighting over one page would be
661 worse than either, so `Finger` and `FingerEnd` keep the direct path.
662 - **The phase is set explicitly before each glide frame** rather than
663 inherited: a stale `FingerEnd` would tell the engine every frame that a
664 gesture had just ended.
665 - **`tick` asks for a rebuild while the glide is in flight**, the same way the
666 chrome's unfold does — that is what keeps the runner's loop turning.
667 - Settings are read once per process (`scroll_settings` is a `OnceLock`), so a
668 change to `input.kdl` needs a restart.
669
670 Measured in a headless shadow at scale 2, one notch: eased it walks
671 92 → 123 → 145 → 159 → … → 190; direct it lands on 190 immediately. Three
672 notches in quick succession land exactly three notches on (190 → 760), so the
673 distance a notch travels is unchanged — only its timing. (The 190 is WebKit's
674 own multiplier on a precise delta; the direct path has always moved that far.)
675
676 ## Vi mode
677
678 `browser.vi-mode` (default **off**; a toggle on cce-system-interface's Browser
679 page) turns the keyboard modal, after qutebrowser. `src/vi.rs` is the
680 engine-free half — modes, the `BINDINGS` table, the count/sequence parser
681 (`Keys`), hint labels, `:` parsing, and the scripts; `vi_*` in `main.rs` does
682 the commands; the WPE host runs the scripts (`vi_eval`, `set_vi_enabled`)
683 and the find controller (`find*`). On Servo those host calls are no-ops.
684
685 Modes: **normal** (keys are commands), **insert** (keys go to the page;
686 Escape leaves), **passthrough** (`Ctrl+V`: *every* key goes to the page, the
687 chrome's Ctrl chords included; Shift+Escape leaves), **hint** (`f`, `F`,
688 `;b`, `;y`), **command** (`:`, `/`, `?`). The status line is a plate at the
689 bottom-left (lifted over a bottom bar); normal mode with nothing pending
690 shows nothing.
691
692 The bindings are qutebrowser's where the browser has the feature: `j/k/h/l`,
693 `Ctrl+D/U/F/B`, `gg`/`G` (with a count, a percent), `H`/`L`, `r`, `J`/`K`
694 and `gt`/`gT`/`g0`/`g$`/`Alt+n` for tabs (`3gt` is tab 3), `d` / `u` close
695 and reopen, `co` close others, `o`/`O`/`go`/`gO` the URL bar (O submits into
696 a new tab — `url_new_tab`), `yy`/`yt`, `p`/`P`, `i`, `gi`, `n`/`N`, `M`
697 bookmark, `Sb`/`Sh`, `gu`/`gU`. `:` knows `open [-t|-b]`, `tabopen`, `back`,
698 `forward`, `reload`, `tab-close`, `tab-only`, `undo`, `buffer N`, `yank
699 [title]`, `bookmarks`/`history`/`downloads`/`favorites`, and `q` (which
700 quits like a window close — the tabs are kept). Points that are choices:
701
702 - **The vi stage sits ahead of the chrome's Ctrl chords** in
703 `handle_key_input`, so normal mode's `Ctrl+D/U/F/B` scroll (qutebrowser)
704 instead of bookmarking or opening pages — `M` and `Sb` are the vi route
705 there. A chord vi does not bind still reaches the chrome (`Keys::takes`),
706 and Ctrl+Shift is spelled apart (`<C-S-d>`), so the favorites chord
707 survives. It stays out while the URL bar or the bookmarks search has the
708 keyboard.
709 - **Unbound letters are swallowed, unbound named keys are not** (qutebrowser's
710 `forward_unbound_keys = auto`): arrows, Page Up/Down, Space, Enter and Tab
711 still reach the page. A swallowed press's release is swallowed too
712 (`vi_swallowed`, keyed case-folded so `G` released as `g` matches), even
713 across the mode change the press caused.
714 - **Insert mode follows a click, not focus.** A page autofocusing its search
715 box leaves normal mode alone, so `j` still scrolls. Two signals: a focus
716 watcher in every frame (private world `cce-vi`, channel `cceVi`) reports
717 whether the focused element takes text, and a report of "yes" within
718 `VI_CLICK_WINDOW` of a page click enters insert; and after every normal-mode
719 click the chrome asks the top frame (`active_editable_js`), because clicking
720 a field that *already* had focus moves no focus and the watcher stays
721 silent. A "no" report while in insert leaves it. A *load* (not a pushState —
722 a search field rewriting the URL per keystroke must keep the keyboard), a
723 tab switch, and closing the tab all drop back to normal; a navigation also
724 forgets the click, so the next page's autofocus is not "clicked into".
725 - **Hints are drawn by the chrome and followed by real clicks.** The script
726 (`hints_js`) returns visible rects in the top viewport's CSS pixels — the
727 chrome's logical pixels — descending into same-origin frames and skipping
728 anything covered at its middle; the labels are prefix-free and as short as
729 the count allows (`hint_labels`, qutebrowser's mixed-length scheme). Picking
730 one sends a pointer move, press and release at the rect's middle, so the
731 page sees a person's click: user activation, focus, even a cross-origin
732 frame under it. `F` is the same with button 2, which `decide-policy` already
733 turns into a background tab. Cross-origin frames' *contents* get no labels
734 (the script runs in the top frame). The wheel, a click, a resize or a
735 navigation takes the labels down — they would no longer match the page.
736 - **Scrolling has two routes.** `j/k/h/l` go through the eased wheel model
737 (half a notch a line), aimed at the page's middle (`scroll_origin`) rather
738 than the resting pointer; `Ctrl+D/U/F/B` and `gg`/`G` are `scroll_js`,
739 exact fractions of the viewport — WebKit scales wheel deltas (the 190 for
740 76 above), so a half page by wheel would be a guess. The script scrolls
741 whatever scrolls under the view's middle, falling back to the document.
742 - **Search is WebKit's find controller**: smart case, wrapping, the current
743 match from the last position; `n`/`N` step, Escape clears the highlights,
744 "Text not found" comes from `failed-to-find-text`. It is the only
745 find-in-page there is.
746
747 ## Account autocomplete (cce-secrets)
748
749 A login field on a page gets a list of the accounts the keyring holds for that
750 site; picking one fills the username and password. A sign-in the keyring does
751 not know yet is offered for saving (Save / Never / Not now), and a saved entry
752 is offered from then on. There is no cce-secrets
753 *protocol* — that app fronts the freedesktop **Secret Service** (gnome-keyring
754 here) and so does this, reading the same entries: item label as the title,
755 `UserName` and `URL` as attributes. `browser.accounts` (default true) is the
756 one switch; with it off nothing is injected and the keyring is never opened.
757
758 Three files meet: `accounts.rs` (which entries a host earns, and the worker
759 that reads them), `wpe/formwatch.rs` (the page half), and `AcMenu` in
760 `main.rs` (the list itself, drawn at the field like every other menu here).
761 WPE only — the retired Servo backend has no user-script hooks — so the chrome
762 side is `#[cfg(feature = "wpe")]`, while `accounts.rs` is not.
763
764 The security shape is the design, not decoration:
765
766 - **Everything runs in a private script world** (`formwatch::WORLD`). The page
767 cannot see or replace the watcher's helpers, so it cannot hook the moment a
768 credential is filled, and it cannot post on the chrome's message channel to
769 fake a focused field.
770 - **Every frame, matched by the frame's own origin.** Sign-in forms are often
771 an iframe from another site (iCloud's is `idmsa.apple.com` inside
772 `icloud.com`), and that frame is where the password goes, so accounts are
773 matched against the *frame's* host. Inside the private world
774 `location.origin` is the frame's real origin, so it can be believed; a top
775 frame must still be on the tab's own host. For a frame from another site the
776 page's own accounts are offered too, after the frame's, under a "sign-in form
777 from <host>" line — a deliberate widening (it would hand the page's password
778 to the embedded site if picked) that is what makes iCloud work with an
779 `icloud.com` entry.
780 - **A frame places itself by relay.** It knows its field only in its own
781 viewport, so each frame announces a random token to its parent with
782 `postMessage`; each parent's watcher finds the sending frame by
783 `event.source`, adds its offset and passes it up, and the top reports a
784 `Frame` offset the chrome keeps in `frame_offsets`. Only geometry travels
785 that way (a forgery misplaces the list, nothing more), and the watcher
786 swallows those messages so the page never sees them.
787 - **A fill is an answer, not a script.** The chrome can evaluate script only
788 in the top frame, so a focused field *asks* to be filled on a reply channel
789 (`FILL_CHANNEL`); the host holds the newest asks by token and a pick answers
790 exactly the one the list was opened for, with the credential as data. No
791 password is ever spliced into script source.
792 - **Only a focused document speaks.** The channels are shared by every tab and
793 say nothing about which one spoke, so watchers report and ask only while
794 `document.hasFocus()` — true only in the shown tab, now that the window's
795 focus reaches the page (`WebKitHost::focus`) — and a tab switch drops every
796 queued event and open ask.
797 - **Matching is narrow** (`Account::matches`): exact host, or a *parent* domain
798 covering its subdomains — never upward, never sideways. An entry with no URL
799 falls back to its title against the site name (`GitHub` → `github.com`), the
800 one guess in here, made only when there is nothing better.
801 - **No password is fetched to build a list.** Listing reads labels, usernames
802 and URLs; the pick is what asks the keyring for one secret, by object path.
803 `accounts::Secret` prints as `Secret(…)` so a derived `Debug` on `Message`
804 cannot spill it into a log.
805 - **The fill is re-checked when it lands.** An unlock prompt can put seconds
806 between the pick and the answer, so `fill_account` drops the credential
807 unless the list is still open, still holds that account, the tab is still
808 on the host it was opened for, and the asking document's token still has
809 an ask open.
810 - **Never automatic.** Nothing fills without a pick, nothing submits the form,
811 and a locked collection is skipped rather than unlocked — the browser asking
812 for the keyring password because a page happened to show a login field would
813 be its own phishing lesson. cce-secrets is where unlocking belongs.
814 - The list says so when the page is not https and not loopback
815 (`insecure_origin`): the password would cross the network in the clear, and
816 only the person can decide that is fine.
817
818 Things that were learned the hard way and are easy to undo:
819
820 - **The keyring is read on the first login field, never at launch.** A browser
821 that never sees one never opens the store, which is what keeps this from
822 costing an unlock prompt at login.
823 - **A field can be focused before the index has finished loading** — it always
824 is, on a page that autofocuses. The chrome keeps its own copy of the last
825 field report (`last_field`) and replays it when the index arrives; without
826 that the first login form of a session silently gets nothing. (A replay, not
827 a rescan script, because the field may be in a frame the chrome cannot run
828 script in.)
829 - **The engine's dirty flag is not a navigation.** Clearing the list on
830 `dirty` closed it in the same pump that opened it (title and loading
831 transitions set it too). It is keyed on the tab's URL actually changing
832 (`nav_url`), and form events are drained *after* that check so an event
833 arriving with the load survives it.
834 - **A fill must not report itself.** The `input` and `change` events the fill
835 dispatches — which are the point, since frameworks ignore a plain assignment
836 — came back as "the user typed" and re-opened the list, filtered by the name
837 just filled in. The watcher holds a `filling` flag across the fill.
838 - **CSS pixels are the chrome's logical pixels.** `resize` hands WPE the
839 *logical* size and sets the scale separately, so a viewport rect from the
840 page needs no conversion at any output scale (verified at scale 2).
841
842 ### Saving a new login
843
844 The watcher reports a sign-in going out — a form's `submit` (which fires only
845 once the page's own validation passed), or, on the many pages with no form,
846 Enter in a login field or a button that says it signs in — with the username
847 and password. The chrome holds it as a `SaveOffer` and asks only when the
848 index has no entry for that site with that username (the same set the field
849 would have been offered, so filling from the page's entry into a sign-in frame
850 is not "new"), and the site is not on the never list. Points that are choices:
851
852 - **It writes what cce-secrets writes**: default collection, the page's host as
853 the title, `UserName`, `URL` = the *form's* origin (where it will be offered
854 next), `text/plain`, never replacing an item. cce-keyring-sync adopts it like
855 any keyring-born entry.
856 - **A locked collection is refused, not unlocked** — same rule as listing.
857 - **The offer outlives the page.** It sits in the dot's corner, survives the
858 navigation a sign-in usually causes, and waits for an answer; it is not
859 modal, and only a press on its own plate is its.
860 - **"Never" is per form host**, in `~/.local/state/cce/browser/never-save.txt`.
861 There is no UI to undo it yet; delete the line.
862 - The typed password crosses the watcher's channel as `formwatch::Password` and
863 lives as `accounts::Secret` — both print redacted — and only until answered.
864 - An existing entry is never updated (a changed password is not detected);
865 that needs comparing secrets, which this deliberately never fetches unasked.
866
867 Testing it needs an isolated keyring, never the real one: `dbus-run-session`
868 plus `gnome-keyring-daemon --unlock --components=secrets`, seeded with
869 `secret-tool`, and the browser launched into that bus with
870 `DBUS_SESSION_BUS_ADDRESS`. A `file:` page will not do — its origin is `null`,
871 so serve the fixture over http on localhost (`127.0.0.1` and `localhost` are
872 two origins, which is a cross-site sign-in frame for free). Two traps: give
873 `gnome-keyring-daemon` a **short** `-C` control directory (a long scratch path
874 overflows the 108-byte socket path and fails as "Address already in use"), and
875 run it `--foreground` in the background so one process owns the bus name.
876 `examples/wpe_autofill.rs` covers the engine side (frames, relay, fill, submit,
877 focus gating) with no keyring at all.
878
879 ## Raindrop bookmark sync
880
881 With `browser.raindrop` on, bookmarks sync with Raindrop.io's **Unsorted**
882 collection: a worker thread polls the in-memory bookmarks, runs a three-way
883 merge against `raindrop-sync.tsv` (the pairs as of the last pass), and shows
884 its status on `cce://bookmarks`. **Read `RAINDROP-SYNC.md` before touching
885 `src/raindrop/`** — every rule in the merge (identity by Raindrop id, only link
886 and title ever sent, the deletion guard, the base recording what *happened*)
887 exists because the alternative silently deletes or duplicates bookmarks. The
888 token is a keyring entry `service=raindrop.io` with no `UserName`;
889 `cce-browser --raindrop-plan` is a read-only dry run against the real account,
890 and `CCE_RAINDROP_API` points everything at a stand-in for testing.
891
892 ## `cce://` pages
893
894 `CceProtocol` registers the `cce` scheme with Servo's `ProtocolRegistry`, so
895 `cce://history`, `cce://bookmarks`, `cce://favorites`, `cce://downloads` and
896 `cce://cookies` are **real pages fetched through Servo's network stack** and rendered
897 like any other. That is why every mutating action is an ordinary link
898 (`cce://history/clear`, `cce://bookmarks/remove?url=…`,
899 `cce://favorites/up?url=…`) — no chrome plumbing needed.
900
901 ### Servo leaks a document per load — the biggest live hazard
902
903 Measured 2026-08-27: a page on a 1 s reload loop grows RSS ~0.9 GB per 90 s, linear,
904 never reclaimed. Isolated cleanly — the same page's JS churn *without* the reload is
905 flat, and an animation-heavy real page (cloudflare.com fully loaded) is flat. It is
906 navigation that leaks, not script. This is upstream in Servo and not fixable here.
907
908 In the wild it took the whole machine down: a **Cloudflare interstitial**
909 (`"Just a moment..."`) re-runs itself waiting on a browser-integrity check Servo can
910 never pass, and reached **54 GB RSS in ~6 minutes** — 83% of a 62 GB box, everything
911 stalling on reclaim. Ctrl+Shift+O (hand the page to another browser) is the escape
912 hatch, and the reason it exists.
913
914 **`cce://downloads` is the same hazard in our own code**: it carries
915 `<meta http-equiv="refresh" content="1">` while any transfer is active, so watching a
916 long download leaks at the rate above. Fixing it means live progress without a
917 navigation, and the obvious route is closed — **`fetch()` cannot reach a `cce:` URL**
918 (tried, including with `Access-Control-Allow-Origin: *`; the protocol registry appears
919 to serve top-level navigations only, and the fetch just rejects). A fix needs either a
920 real localhost HTTP endpoint the page can fetch, or progress moved into the chrome.
921 Until then, don't add a self-refreshing `cce:` page, and know this one is live.
922
923 The handler runs on **Servo's fetch threads**, hence the `Arc<Mutex<_>>` stores. It
924 therefore *cannot reach Servo itself*: `cce://cookies/clear` sets an `AtomicBool` that
925 the next `pump` acts on via `site_data_manager()`. Anything else needing engine access
926 from a page has to take the same route.
927
928 History, bookmarks and favorites are TSV under `~/.local/state/cce/browser/`;
929 `sanitize()` strips tabs and newlines because the format has no escaping. All the
930 pages share the `page()` skeleton — restyle there, not per page. A page's own
931 `<style>` goes in through `head_extra`, which lands *before* the skeleton's, so
932 an override has to out-specify it (`.e .w`, not `.w`).
933
934 Clearing cookies is a **confirm-then-act page**, and Ctrl+Shift+Delete opens it rather
935 than clearing outright: sessions persist now, so an accidental chord would sign the
936 user out of everything.
937
938 ## Downloads
939
940 Servo has no download pipeline at all, so a URL that looks downloadable is diverted to
941 a `reqwest` blocking worker that streams it into the download dir. The sniff is
942 **extension-only** (`DOWNLOAD_EXTENSIONS`) — no `Content-Disposition` or content-type
943 handling — so a download URL with no recognizable extension navigates instead.
944
945 It has to happen in **two places**, and that is not redundancy.
946 `WebViewDelegate::request_navigation` fires only for navigations the *content* starts
947 (a link, `location.href`). A URL the **embedder** supplies never reaches it — neither
948 the first tab's, which Servo loads straight from `WebViewBuilder::url`, nor one from
949 the URL bar — so those are sniffed in `ServoHost::take_as_download` instead. Until that
950 existed, `cce-browser https://…/thing.tar.gz` rendered Servo's "Unknown content type
951 (application/octet-stream)" page rather than downloading. A caller that takes a URL as
952 a download must return *without* navigating, which is what stops the two paths from
953 starting the same transfer twice.
954
955 `Download::id` exists because `clear_finished` shifts Vec positions; worker updates
956 must never carry an index across a lock boundary.
957
958 ## Settings and profile state
959
960 `~/.config/cce/cce-browser/config.kdl`, section `browser`, read at startup and re-read
961 in `handle_focus_change` — so edits made in **cce-system-interface's Browser page**
962 (`../cce-system-interface/src/pages/browser.rs`, which owns the writing side) apply on
963 the next switch back. Keep the key names in `settings.rs` and that page in sync;
964 `external-browser` is currently read here with no UI writing it.
965
966 Servo persists per-profile state (cookie jar, auth cache, HSTS) only when given a
967 `config_dir` — without one every launch starts logged out of every site. It lives at
968 `~/.local/state/cce/browser/profile`, forced to `0700` because **the jar is plaintext
969 JSON holding live sessions**.
970
971 Two engine-level settings have sharp edges, both documented at length in `webview.rs`:
972
973 - **CSS Grid ships disabled in Servo** (`layout.grid.enabled`), so every
974 `display: grid` was refused and fell back to block flow. It is turned on explicitly
975 in `Preferences`; other modern-layout gaps are likely the same kind of default.
976 - **Force-dark schedules *two* reloads**, at 400 ms and 2.5 s. User content reaches the
977 script thread as a separate message, and force-dark also flips the reported scheme
978 (it reports *light*, so pages render the light theme the filter then inverts). Either
979 in-flight change can land after a too-eager reload, leaving a page inverted the wrong
980 way with nothing to reload it again. Both deadlines are needed; don't collapse them.
981
982 Servo's own arboard-backed clipboard delegate lands nothing on the clipboard in this
983 embedding (verified by reading the seat's clipboard back), so `CceClipboard` routes
984 through `cce_ui::widget::clipboard` — which also keeps the browser on the same
985 clipboard path as the rest of the DE.
986
987 ## Not implemented yet
988
989 Worth knowing before assuming a bug: no find-in-page outside vi mode's `/`, no zoom, no favicons, and no
990 history/URL autocomplete. (The context menu, JS dialogs and HTTP auth landed with the
991 WPE backend and are Servo-only gaps now.) Account autocomplete saves new logins but
992 never updates a changed password, and "never save" has no undo UI. Ctrl+Shift+O ("hand this page to
993 another browser") is the deliberate escape hatch for pages Servo cannot follow, such as
994 a Cloudflare challenge that never completes.
995
996 Also note `parse_startup_arg` is **not** `parse_url_input` and the difference is
997 tested: the URL bar turns a dotted, space-free word into a domain guess, which would
998 mangle the local file path a launcher is allowed to pass for `%u` into
999 `https:///home/me/page.html`.