web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
WPE-PORT.md (17.6K)
1 # Porting cce-browser from Servo to WPE WebKit
2
3 Status as of 2026-08-30: **WPE is the default engine.** A plain
4 `cargo build --release -p cce-browser` produces the WebKit browser; the Servo backend
5 survives behind `--no-default-features --features servo`.
6
7 The flip was forced by an incident, not a ceremony: while WPE was opt-in, a routine
8 featureless rebuild by another session silently reverted the installed browser to
9 Servo — two days after the port landed, with the user's WebKit-stored logins invisible
10 and the interstitial memory leak live again. An opt-in engine cannot survive a
11 multi-session workspace; defaults are what other sessions build.
12
13 ```sh
14 cargo build --release -p cce-browser
15 ```
16
17 Written 2026-08-27 as a scoping document; kept as the record of what the port
18 involved, what it cost, and what is left.
19
20 ## Why
21
22 Servo cannot render the web we actually browse. Two independent problems, neither
23 fixable in this crate:
24
25 - **Coverage.** CSS Grid shipped *disabled* (`2e700fe` counted 175 refusals on one
26 GitHub page, 33 on an MDN reference); that commit turned it on, but it is
27 representative rather than exceptional — much of the modern platform is missing or
28 off, and pages collapse into a single column.
29 - **Identity.** Anti-bot classifiers model known engines. Servo presents as
30 `Servo/… Firefox/…`, which matches nothing, so Cloudflare's managed challenge fails
31 closed and re-runs forever. Measured cost of that loop: **54 GB RSS in ~6 minutes**
32 (Servo leaks a document per load), which took the whole machine down. See
33 `CLAUDE.md`.
34
35 WebKit fixes both by construction: a complete engine, presenting as
36 `… AppleWebKit/605.1.15 … Safari/605.1.15` — a profile the classifiers model.
37
38 ## Why WPE and not WebKitGTK
39
40 Both are the same engine at `2.52.6` in `extra`. The difference is the embedding
41 contract:
42
43 | | WPE | WebKitGTK |
44 |---|---|---|
45 | render target | you get **buffers**; you composite | a `GtkWidget` in a GTK hierarchy |
46 | main loop | GLib, integratable | GLib **plus** GTK's assumptions |
47 | toolkit dep | none | GTK4 + all of it |
48
49 cce-ui is a custom Vulkan/Wayland toolkit with no GTK anywhere, and `cce-browser`
50 already consumes the engine as *"give me a finished frame, I draw it as one quad."*
51 That is precisely WPE's contract. WPE was built for set-top boxes and embedded devices
52 that own their display pipeline — structurally the same shape as this DE. WebKitGTK
53 would mean running a GTK main loop and snapshotting a widget to recover pixels.
54
55 Rejected outright: **CEF/Chromium.** No Arch package exists at all (checked: not
56 `cef`, `cef-minimal`, `cef-bin`, `libcef`), so it means vendoring a ~1 GB prebuilt
57 tarball or building Chromium; it re-execs *your own binary* for subprocesses, which
58 restructures `main()` around a Wayland client with a custom `calloop` loop; and it
59 ships a runtime data bundle you must locate at startup. `qt6-webengine` is the
60 cautionary tale — the Qt Company, full-time, still vendors 58 `.pak`/`.dat`/`.bin`
61 files and a helper binary, at 282 MB. `webkitgtk-6.0` ships **zero** such files.
62
63 ## Packages
64
65 ```sh
66 pacman -S wpewebkit # extra, ~137 MB; pulls libwpe + wpebackend-fdo itself
67 ```
68
69 Declared like `cce-compositor` declares wlroots: a system prerequisite, `pkg-config`
70 in `build.rs`, bindgen over the headers. This **deletes the Servo build** — the crate
71 stops being a 5-minute, 175 MB outlier and becomes a normal small crate.
72
73 ## API generation: WPEPlatform — resolved
74
75 `wpewebkit 2.52.6-1` ships the **new WPEPlatform API**, confirmed from the package
76 file list. Its `pkg-config` modules:
77
78 ```
79 wpe-webkit-2.0 wpe-platform-2.0 wpe-web-process-extension-2.0
80 wpe-platform-wayland-2.0 wpe-platform-drm-2.0 wpe-platform-headless-2.0
81 ```
82
83 One library — `libWPEWebKit-2.0.so.1` — with the backends as separate modules. The
84 package still *depends* on `libwpe` / `wpebackend-fdo` (the legacy path is built too),
85 but the port targets `wpe-platform-2.0` and never touches them directly: no
86 embedder-supplied backend, which was the fiddliest part of the old generation.
87
88 The 46 WPEPlatform headers map onto `ServoHost` almost object for object:
89
90 | WPE | replaces |
91 | --- | --- |
92 | `WPEDisplayHeadless` | `SoftwareRenderingContext` — we own the display, no windowing assumptions |
93 | `WPEView` | Servo's `WebView`; one per tab |
94 | `WPEBufferSHM` / `WPEBufferDMABuf` | `read_to_image` — **both phases exist as first-class types** |
95 | `WPEEvent`, `WPEInputMethodContext` | `notify_input_event`; IME is a bonus we do not have today |
96 | `WPEToplevel`, `WPEScreen` | resize / scale plumbing |
97
98 `WPEDisplayHeadless` is the one to use: WPE composites nothing, hands us buffers, and
99 cce-ui draws them — exactly the current model. (`WPEDisplayWayland` exists but would
100 make WPE a Wayland client in its own right, which fights cce-ui compositing.) Headless
101 also means the throwaway spike and the shadow-session tests need no display at all.
102
103 ### The frame contract, read off the installed headers
104
105 ```
106 WPEDisplayHeadless wpe_display_headless_new()
107 └─ WPEView wpe_view_new(display) ← we subclass this
108 └─ WebKitWebView webkit_web_view_new(backend)
109 webkit_web_view_get_wpe_view() / _get_display() tie the layers together
110 ```
111
112 Frames arrive through the **`WPEViewClass.render_buffer` vfunc**:
113
114 ```c
115 gboolean (*render_buffer)(WPEView *, WPEBuffer *, const WPERectangle *damage_rects,
116 guint n_damage_rects, GError **);
117 ```
118
119 Cast to `WPEBufferSHM`, then `wpe_buffer_shm_get_data()` → `GBytes` → the pixels,
120 with `wpe_buffer_get_width/height` and `wpe_buffer_shm_get_stride/get_format`.
121
122 **Two things this buys us that the Servo path never had:**
123
124 - **Backpressure is built in.** You call `wpe_view_buffer_rendered(view, buffer)` when
125 you are done with a buffer. The engine cannot outrun the compositor, because buffer
126 lifetime is explicit and ours. That is structurally the opposite of the unbounded
127 `PENDING` queue the Servo path pushes into (see `CLAUDE.md`) — the class of bug
128 simply cannot arise.
129 - **`damage_rects`.** Partial updates are available whenever we want them; today every
130 frame is a full-window repaint.
131
132 **The embedder subclasses two types, not one.** `WebKitWebView`'s `display` property
133 is construct-only and takes a **`WPEDisplay`**, not a view — WebKit makes its own view
134 by calling `WPEDisplayClass.create_view`. So we implement a `WPEDisplay` that vends our
135 `WPEView`, and the view overrides `render_buffer`. (`WPEDisplayHeadless` is
136 `G_DECLARE_FINAL_TYPE`, so it cannot be subclassed to shortcut this.)
137
138 ## The embedding contract, learned the hard way
139
140 Established by a C spike (now `spike/wpe-spike.c`) and unchanged by everything built
141 on top of it. **The two traps below cost hours and neither produces an error message**,
142 so they are the part of this document most worth keeping.
143
144 Working from the start:
145
146 - `pkg-config` → compile → link against `wpe-webkit-2.0` + `wpe-platform-2.0`.
147 - Subclassing `WPEDisplay` *and* `WPEView`, overriding `connect`, `create_view` and
148 `render_buffer`. Flagged as the main FFI risk; mechanical in both C and Rust, and
149 ordinary GObject rather than a hack.
150 - `g_object_new(WEBKIT_TYPE_WEB_VIEW, "display", display, NULL)` — the WPEPlatform
151 construction path. WebKit calls our `create_view`, and
152 `webkit_web_view_get_wpe_view()` returns the instance we handed it.
153 - **The sandbox is a non-issue.** The full engine starts: `WPENetworkProcess` plus
154 `WPEWebProcess` under `bwrap`, with no special setup. Retire that risk.
155 - View sizing/mapping: `wpe_view_resized` / `set_visible` / `map` all take.
156
157 **The spike renders.** `example.com` came out pixel-correct — right fonts, right link
158 colour, right layout. Two things were needed beyond the above, and both are
159 non-obvious:
160
161 - **A `WPEToplevel`.** WebKit asks the **toplevel** for buffer formats
162 (`WPEToplevelClass.get_preferred_buffer_formats`), not the display. With
163 `WPEDisplayClass.create_toplevel` left NULL, no formats are ever negotiated and
164 `render_buffer` simply never fires — with no error. Subclass `WPEToplevel`
165 (derivable; construct properties are `display` and `max-views`) and implement
166 `get_preferred_buffer_formats` plus `resize`.
167 - **Both halves of the buffer handshake.** `wpe_view_buffer_rendered` means
168 *displayed*; `wpe_view_buffer_released` means *the memory is yours again*. Calling
169 only the first yields exactly one frame and then a permanent stall. Call both.
170
171 That second point is the backpressure mechanism, working as advertised: the engine
172 will not produce another buffer until the embedder hands one back. The unbounded-queue
173 failure mode is impossible here by construction.
174
175 ### The staging holds: SHM is real
176
177 Buffers arrive as **`WPEBufferSHM`**, despite `wpe_display_headless_new()` advertising
178 54 DRM fourcc formats and inferring a DRM device — the reference display being
179 GPU-backed does not force the embedder to be:
180
181 ```
182 render_buffer #2: 1200x800 type=SHM bytes=3840000 stride=4800 format=0
183 ```
184
185 `format=0` is `WPE_PIXEL_FORMAT_ARGB8888`; stride is `width * 4`; byte order in memory
186 is B,G,R,A. That is precisely the shape `cce_ui::vk::upload_rgba` already accepts.
187
188 **So Phase 1 needs no `cce-ui` change**, and the Vulkan `VK_EXT_external_memory_dma_buf`
189 import stays a Phase 2 optimisation rather than a day-one prerequisite in a shared
190 crate. (An earlier revision of this doc recorded the opposite as a live risk; the spike
191 settled it.)
192
193 A static page yields two frames and then quiets, which is correct — no animation, no
194 new frames.
195
196 ## Impact map
197
198 | file | what happened |
199 | --- | --- |
200 | `src/wpe/` (new, ~900 lines) | `WebKitHost` plus the three GObject subclasses, the input mapping, and the GLib↔calloop bridge. |
201 | `src/webview.rs` | **kept.** `ServoHost` is still the default backend. It grew `key_ui` / `mouse_button_ui` / `editing_action_cmd` / `set_color_scheme_dark` taking cce-ui types, so both hosts present one surface. |
202 | `src/main.rs` | **kept**, with `Host` a compile-time alias for one backend or the other. `dom_key` and `dom_button` moved out; the chrome now names neither engine. `register_sources` is the only place they visibly differ. |
203 | `src/pages.rs` | **kept.** `CceProtocol::route` was extracted so both backends share one routing table — Servo through `ProtocolHandler`, WebKit through its URI-scheme callback. |
204 | `src/downloads.rs` | **kept**, plus `adopt` / `set_progress` / `set_finished` for engine-driven transfers. Under WPE the extension sniff is never reached. |
205 | `src/settings.rs` | **kept**, plus `is_dark()` replacing the `servo::Theme` conversion. |
206
207 Nothing was deleted. Both backends compile from the same source, which is why the
208 Servo path could stay green throughout.
209
210 Still available and **not yet taken**: JS dialogs (`script-dialog`), HTTP auth
211 (`authenticate`), permission requests (`permission-request`), find-in-page
212 (`WebKitFindController`), zoom (`webkit_web_view_set_zoom_level`). Each is a signal
213 away now that the host exists.
214
215 ## The frame path
216
217 **Phase 1 shipped: SHM buffers straight into `upload_rgba`, no `cce-ui` change.**
218 Frames arrive as `WPEBufferSHM`, ARGB8888, stride `width * 4`, B,G,R,A in memory —
219 precisely what the registry already accepts.
220
221 Phase 2 — dmabuf imported as a Vulkan image via `VK_EXT_external_memory_dma_buf`,
222 skipping the CPU roundtrip — remains available and unstarted. It needs a **new
223 `cce-ui` API** (the registry only accepts `Vec<u8>`), and `cce-ui` is a **shared
224 crate**: check `git status` there and coordinate before touching it. It is an
225 optimisation, not a correctness fix; the port works without it.
226
227 ## Bindings: hand-rolled, like wlroots
228
229 There are no usable Rust bindings.
230
231 - `wpe` — 0.0.19, last published **2023-03**. Abandoned.
232 - `cogcore-sys` / `cogcore` — FFI to Igalia's Cog launcher, recent (2026-08) but **92
233 downloads**. Not a dependency; worth *reading* as prior art for the FFI shape.
234 - The `webkit` crate is macOS `WKWebView`. Irrelevant.
235
236 So: bindgen over the C headers, exactly the idiom `cce-compositor/build.rs` already
237 uses against wlroots. `build.rs` does this, gated on `CARGO_FEATURE_WPE`, so a default
238 build needs no WPE headers.
239
240 **This was predicted to be the main risk and was not.** Subclassing `WPEDisplay`,
241 `WPEView` and `WPEToplevel` from Rust is mechanical: `g_type_query` reports the
242 parent's instance and class sizes at runtime, `g_type_register_static_simple`
243 registers against those, and the class structs are public so installing a vfunc is a
244 field assignment. That is *more* robust than the C spike, which bakes the layout in at
245 compile time. Friction amounted to two things: `GClassInitFunc` is already an
246 `Option<fn>` and must not be wrapped again, and `gsize` is `u64`.
247
248 ## Risks, as they actually landed
249
250 The ranking was wrong in an instructive way: the mechanical risks were cheap and the
251 undocumented-protocol ones were expensive.
252
253 1. ~~**FFI surface is hand-built.**~~ Retired. Mechanical, see above.
254 2. **ABI churn.** Unchanged and unavoidable. WebKit majors move, Arch is rolling;
255 expect periodic build breaks against `wpe-webkit-2.0` / `wpe-platform-2.0`.
256 3. ~~**Multi-process and the sandbox.**~~ Retired. `WPENetworkProcess` and
257 `WPEWebProcess` come up under `bwrap` with no special setup.
258 4. ~~**GLib main loop vs `calloop`.**~~ Done. `register_sources` registers the epoll fd
259 carrying GLib's pollfd set, plus a timer from `poll_timeout`. Measured at **63
260 wakeups per 8s against 495** for the fixed-interval version it replaced.
261 5. **Cloudflare remains unproven**, and is no longer on the critical path — see below.
262
263 **The real cost was none of these.** It was the object graph: that `WebKitWebView`
264 takes a `WPEDisplay` and makes its own view, that a `WPEToplevel` is required at all,
265 and that the buffer handshake has two halves. Every one of those fails *silently* —
266 no error, healthy web process, simply no frames. Better bindings would not have helped
267 with any of them.
268
269 ## Real-world use — started 2026-08-28
270
271 The WPE build is installed and in daily use. What that has established, and what it
272 has cost, in the first hours:
273
274 **Cloudflare: signed in successfully**, dashboard and all. Servo could not get past the
275 interstitial at all — it re-ran the challenge until the machine died. This is not quite
276 proof that WebKit passes a *Managed Challenge* specifically (the session may never have
277 been served one), but it is a far higher bar than the marketing page that earlier test
278 used: a heavy JS application behind Cloudflare's own protection, reached through a
279 login. The engine-identity worry that motivated half this document has not materialised.
280
281 **Website data persists — but cookies did not, at first.** What survived restarts
282 in the early days was WebKit's origin-keyed store under
283 `~/.local/state/cce/browser/profile/storage` (localStorage, IndexedDB, service
284 workers), which a persistent `WebKitNetworkSession` writes on its own — and which
285 made this section originally claim "cookies persist". They didn't: WebKit's cookie
286 store is memory-only until `webkit_cookie_manager_set_persistent_storage()` names a
287 file, so cookie-backed logins (Google) evaporated with the process while
288 token-in-localStorage logins (Cloudflare) survived, disguising the gap. Fixed
289 2026-09-01: cookies now live in `profile/cookies.sqlite`. (`cookie_jar.json` beside
290 it is Servo's format, now dead weight.)
291
292 **Three bugs found by use, none by testing:**
293
294 | symptom | cause |
295 | --- | --- |
296 | pages rendered half size | `resize` took physical pixels and never told WPE the scale, so a 2x display laid out 2400x1600 *CSS* pixels |
297 | Ctrl+V did nothing in a page | `WPEDisplayClass.get_clipboard` left NULL — WebKit had no clipboard at all |
298 | …and still did nothing once added | the `WPEClipboard` subclass overrode `changed` without chaining up, so `set_content` stored nothing and WebKit never called `read` |
299
300 The scale bug is the instructive one: **every test up to that point ran at scale 1**,
301 where the physical/logical conversion is the identity, so nothing scale-dependent was
302 ever exercised. A whole class of bug was invisible to the entire test suite. The same
303 was true of input coordinates, which had the identical latent bug and were fixed in the
304 same pass before anyone hit them.
305
306 ## Still unproven
307
308 - A live `"Just a moment..."` interstitial, specifically. Cheap to settle now that the
309 WPE build is a real browser rather than a Python harness.
310 - Everything past the first hours of use.
311
312 ## What remains
313
314 Roughly in order of what would decide whether WPE becomes the default:
315
316 1. **Soak it on real sites.** The gap named above. Everything else is speculation
317 until someone browses on it.
318 2. **Settle Cloudflare**, next time a live challenge appears.
319 3. **Clean up the Servo-shaped seams in `main.rs`.** `focus()` is a no-op on the Servo
320 side, and `set_force_dark` is only called under the feature. Both are honest
321 scaffolding for running two backends at once, and both should go when one wins.
322 4. **Take the free WebKit features** — JS dialogs, HTTP auth, permissions,
323 find-in-page, zoom. Each is a signal.
324 5. **Phase 2 dmabuf**, only once the rest is solid, and only after coordinating on
325 `cce-ui`.
326
327 ## Verifying it yourself
328
329 The examples are the test suite; all need `--features wpe`.
330
331 | example | what it demonstrates |
332 | --- | --- |
333 | `wpe_host` | boot, frames, page state, navigation, history |
334 | `wpe_input` | pointer / keyboard / wheel reaching the page, read back via `document.title` |
335 | `wpe_tabs` | several views on one display, and a **backgrounded** tab still updating |
336 | `wpe_loop` | blocking on GLib's fds vs polling, with the wakeup counts |
337 | `wpe_dark` | force-dark, asserted on rendered pixels rather than on the call |
338
339 `spike/wpe-spike.c` is the original C spike, kept because it is the shortest complete
340 statement of the embedding contract.