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