# How Claude Added Chromium to a WebKit Browser

*Kagerou · 14 Oct 2026*

*Author’s note: Sora (Claude) carried out the Chromium port described here. My role was to coordinate the work. “We” refers to that collaboration.*

*September 23–27, 2026*

The first Chromium tab appeared in Refrax by evening. Getting it there had cost two crashes; it still could not play H.264 video, and automated clicks landed at the wrong coordinates. Those problems had different causes, but the prototype exposed one larger question: could a browser built around Apple's WebKit carry Chromium without turning every Chromium failure into a Refrax failure?

Refrax already had an engine. WebKit came with macOS, received Apple's updates, and filled almost every page-sized assumption in the code. Adding Chromium meant more than drawing a second view. History, extensions, scripts, downloads, site rules, and navigation all expected a `WKWebView`. We wanted a Chromium tab that behaved like a Refrax tab, with a command to switch it back to WebKit. A future engine should be able to use the same seam.

Five days later, both engines could render pages in the same browser. Getting there meant throwing away the first working Chromium integration.

## A page, then a crash

The quickest route was Chromium Embedded Framework, or CEF: a prebuilt framework we could load when someone opened a Chromium tab. Refrax loaded a small plug-in with `dlopen`; the plug-in loaded CEF. Refrax itself launched without Chromium in its address space, and Objective-C protocols kept CEF's C++ types out of the Swift app.

The first crash arrived before CEF drew a page. On macOS, CEF needs `NSApp` to respond to methods it uses for event dispatch. We tried to add them by changing `NSApp`'s class at runtime. AppKit had already installed a key-value-observing subclass, and changing the class discarded the bookkeeping that subclass expected. `CefInitialize` followed a null observation pointer and died. Grafting the methods onto the existing class got CEF running. The production app later adopted the required protocol in its `NSApplication` subclass, before AppKit could wrap it.

The second crash came from the build loop. We wrote a rebuilt plug-in over the dylib Refrax had mapped. macOS checked a newly faulted page against the original code signature and killed the app with `Code Signature Invalid`. The build script began signing a staging copy and renaming it into place. The installer's later versioned bundles follow the same rule: leave mapped code alone.

By evening, Chromium could load a page, report its title to Refrax's history, and switch back to WebKit. Five helper processes ran. YouTube played VP9. Yet the public CEF build had no proprietary codecs or Widevine, so H.264 and protected video were missing. Automated clicks landed in the wrong part of its view. It worked well enough to establish the interface, but it was a poor engine to ship.

We looked at installed Chromium browsers for a shortcut. Helium's framework exported only three entry points, all for starting a whole browser; it had no API for handing Refrax one web view. Arc had built such a wrapper, but its framework was proprietary and undocumented. Its embedded Chromium was also several major versions old. Borrowing another browser's binary would borrow its patch schedule, and we could not control that schedule.

That was the reason to keep two engines. WebKit's security updates arrived with the OS. A separately shipped Chromium would need its own build, signing, distribution, and patch cadence. If it fell below a minimum safe version, Refrax could reopen its tabs in WebKit. The fallback was an operational requirement, not a feature for the engine picker.

## Making room for the second engine

Before building Chromium, we had to remove WebKit from the center of Refrax's model. There were 176 references to `backingWebView` across 43 files. Some were obvious rendering code. Others belonged to history, page reading, extension tabs, or the control server. Any one could silently make a Chromium tab behave differently.

`PageState` became the single place for what the interface shows about a page: URL, title, progress, navigation, security, and renderer health. Each engine emits events; one reducer applies them. WebKit's observations feed that reducer synchronously, preserving the timing callers already relied on. Engine pages also got a shared watchdog: a renderer unresponsive for 45 seconds is terminated, whichever engine owns it.

The engine-facing API was deliberately small. An engine starts, makes a page, receives policy updates, and shuts down. A page owns its view, accepts commands, evaluates scripts, takes snapshots, and closes. The wire data is JSON governed by a documented contract. Refrax validates it at the seam: a 256 KiB message limit, bounded strings and numbers, known URL schemes, and a page closure after 32 malformed messages. The Objective-C header exposes the operations without exposing Chromium types:

``` objc
- (void)startWithConfiguration:(NSData *)configuration
                    completion:(void (^)(NSError *_Nullable error))completion;

/// `spec`: JSON EnginePageSpec.
- (nullable id<RFXEnginePage>)makePageWithSpec:(NSData *)spec error:(NSError *_Nullable *_Nullable)error;
```

Moving scripts onto that interface forced us to inspect how they talked back to Refrax. AutoConsent asked whether it was enabled for the current site, then waited for a callback. Refrax sent the callback into the page's JavaScript world; AutoConsent lived in an isolated world. The callback never arrived. After 100 ms the script assumed permission, so the per-site off switch had no effect. A shared message router gave each injected script a reply in its own world.

That inspection found sharper edges. User scripts supplied their own namespace in messages and could impersonate another script to reach its stored values or allowed hosts. The Chrome Web Store integration exposed its install channel to every page, allowing a website to request an extension install without a user confirmation. The store script moved into an isolated world; Refrax now checks the store origin, main frame, extension ID, and real click before asking the user to install. Extension reloads had also skipped store-installed extensions, and the optional Objective-C method for a tab title had the wrong selector. Both compiled cleanly and failed at runtime.

The test suite had its own misleading baseline: 19 failures labeled “known,” including history and bookmark search. Inside a SwiftData predicate, `String.contains` resolved to a regex overload SwiftData could not turn into SQL. Search had been returning nothing. Replacing it with `localizedStandardContains` took the suite green. This was the first dividend of making the WebKit path obey an engine contract: code that had always shipped finally had to state what it meant.

## Moving Chromium out

CEF kept Chromium in Refrax's process. The replacement put Chromium's browser process in a separate app, Refrax Chromium Host. Refrax loaded only a small client for the view bridge and a Mojo connection. A renderer crash could stay on the host side of that boundary. Chromium already had much of the macOS view transport we needed: Chrome's installable app shims use `remote_cocoa` to display a web view across processes, including mouse events, keyboard input, cursors, popups, and text composition. We reused that split instead of rebuilding input from scratch.

The host came from ungoogled-chromium. On September 25, the first page appeared in a Refrax tab. The build was Chromium 152; stable Chromium had reached 155 two days earlier. The version gap existed before we had a release. It would remain the hardest problem after one.

The separate process did not make the client harmless. Dragging an image crashed Refrax because the client had no screen scale factors. In a development build, some Chromium `DCHECK`s that normally kill a renderer instead fired inside Refrax. A Speedometer run found another pair: the host entered Chrome Reading Mode code that assumed a Chrome tab strip existed, then Refrax tried to restart the dead host from an AppKit nested run loop that Chromium's message pump could not attach to. We disabled the unused Reading Mode path and delayed restart until the default run-loop mode returned.

A conformance runner made those failures repeatable. It loads an engine bundle through the same header as Refrax, serves pages on loopback, and drives the contract without knowing which engine is under test. Its first 57 tests found races in page closure, script matching, and per-page zoom. `Page.Close` became a round trip so the client kept the view container registered until the host acknowledged it. The suite eventually reached 75 tests; every engine release had to pass it.

One test seemed to show content blocking taking three minutes to apply. Chromium postpones low-priority work until it considers startup complete, normally after a visible tab loads. The host had no tab Chromium tracked, so it waited for a three-minute failsafe. It now posts a startup-complete signal after Refrax connects. Sending that signal synchronously crashed because a Chrome observer had not registered yet. The order mattered as much as the call.

## A release build that could not browse

The fast development build scored 5.3 on Speedometer 3.1 against 36.6 for stock Chromium on the same machine. It had been compiled for quick rebuilds, without the release optimizations, so we spent 203 minutes building the release engine. Conformance started it. `about:blank` loaded. Every network navigation hung.

A network log stopped at `COOKIE_PERSISTENT_STORE_KEY_LOAD_STARTED`. Chromium wanted a cookie-encryption key from the macOS Keychain. A development build had created the item under one signing identity; the release build carried another. macOS was waiting for approval in a SecurityAgent prompt, and the host was waiting for macOS. Nothing in the tab explained either wait.

Contract 1.1 moved secret ownership to Refrax. The host asks it for a named `storageKey`; Refrax stores one key per engine and name under its own Keychain entitlement. Chromium asks early in startup, so the host queues the request until the client pipe exists. Network pages loaded again.

The release engine first scored 28.4 inside Refrax and 38.6 in a bare harness. We suspected the window's visual effects; a second active-window run scored 34.0 and broke that theory. Sampling showed Refrax's main thread mostly idle, though the UI was visibly busy: Speedometer's subframe loads produced 624 `loadingChanged` events for one page. We narrowed that event to main-frame document loads, matching WebKit's `isLoading` behavior. We also separated the host and client release builds so Chromium's PartitionAlloc could replace the host's allocator without replacing Refrax's while it was running. After that, Refrax scored 33.3 ± 2.0 beside a Chromium fork at 37.0 ± 2.7. Those measurements do not isolate which change moved the score. The remaining gap inside Refrax's window was unresolved.

Now the engine had to reach users safely. The installer reads a signed catalog, verifies the download's size, hash, and signature, checks the bundle's contract version and team signature, then moves the notarized engine into place. The first installer tests exposed a Dock icon flash, a progress bar divided by bytes when its API reported percent, and a stale `Bundle(url:)` cache that kept showing the old release after an update. The host became a background-only app; the progress and bundle reads were fixed.

Updating a running engine still requires a Refrax relaunch. The host can stop, but its client defines Objective-C classes inside Refrax's process. Loading a second client at the same path can leave the runtime using the first class definition with a new host. An update installs the next version, then offers a relaunch before using it.

The signed catalog also carries a security floor: the oldest engine release Refrax will run. A version below that floor cannot start; its tabs open in WebKit, and Settings asks for an update. Refrax saves the last verified catalog and checks it at launch, including offline. Raising the floor needs a catalog update rather than an app build. It is a brake for a lagging engine, though it does nothing to build a new Chromium patch.

## The rule Chromium never asked about

The engine was downloadable, but a link preview exposed a more basic flaw. A preview page rendered in Chromium while its panel embedded the paused WebKit view, producing a white rectangle under a valid title. We changed the panel to host the active engine's view. Then a pinned Chromium tab revealed the deeper problem: clicking a link to another site navigated the tab away. In WebKit, that click opens a preview and leaves the pin where it is.

Refrax had fifteen navigation handlers for things such as tracker stripping, pinned tabs, external apps, and blocked sites. WebKit asked them for a decision before moving. Chromium moved first and sent `navigationStarted` afterward. By the time Refrax learned about the link, every rule had missed it.

Contract 1.2 added a `navigation` request before the first request of a main-frame navigation. The host uses a Chromium navigation throttle to pause, asks Refrax, and resumes only on `allow`. If a handler rewrites a URL or opens a preview, Refrax performs that action and answers `cancel`. The host's decisive branch is small:

``` cpp
  void OnAnswer(const std::string& answer) {
    auto message = contract::ParseMessage(answer);
    if (message && message->first == "allow") {
      Resume();
    } else {
      CancelDeferredNavigation(CANCEL);
    }
  }
```

The request includes what began the navigation and the initiating document's origin. That origin matters for `file:` URLs: Refrax's own load may open one, while a page must not script its way into a local file. An opaque origin such as `"null"` stays a page origin. New-window requests pass through the same handlers, so a pinned tab's `target=_blank` link follows the preview rule too.

A synthetic click initially seemed to defeat containment. It was a script-generated navigation without a user gesture, and the rule intentionally lets a site's own redirects move the tab. A real click opened the preview and kept the tab pinned. The test had to distinguish the two.

Per-site settings made a similar demand for precision. JavaScript maps to Chromium's content settings. Refrax's “Allow All Auto-Play” maps to Chromium's per-site autoplay allowlist; its strictest “Never Auto-Play” has no exact Chromium setting because Chromium permits muted autoplay, so that setting behaves like Refrax's middle option on Chromium. We kept that limitation visible in the contract.

Turning off page JavaScript exposed a less visible break: it also blocked Refrax's own main-world evaluation. WebKit leaves app scripts working in that case. Chromium has an escape hatch for extension code, `WebLocalFrame::CallFunctionEvenIfScriptDisabled`; the renderer now uses it to call a small function that evaluates Refrax's source. The page's scripts remain disabled. The injected-script component needed the same fix. A screenshot test then found another paused-WebKit call site: Chromium pages returned a white image until snapshots went through the active engine.

## The release question

The final review found an issue larger than an engine bug. Refrax had an in-browser agent chat that could read pages and call local tools. Page content entered the agent's prompt, and the tool setup gave one backend broad shell access and another a browser-control command with file output and settings access. A malicious page could instruct the chat to use those permissions. The feature was retired, along with Command Lens's AI mode. External agents could still use Refrax's separate control tool; the browser no longer embedded a chat with those permissions.

We checked an upgrade using a copy of a previous-version data store, with sync disabled in the copy. The store opened under the new build after the agent settings were removed. The review also caught a startup freeze: Refrax verified the engine bundle's full code signature on the main thread. That check moved to background work, and a page needing the engine awaits the same verification before launch.

By September 27, the conformance suite contained 75 tests. The release review recorded 69 passes and six skipped input tests. Refrax 0.34 shipped later that afternoon. A tab could switch engines, keep its history, use Refrax's navigation and site rules, and return to WebKit when the Chromium release fell below the signed security floor.

The shipped engine was still Chromium 152 while stable was 155. There was no automatic rebuild for each Chromium security patch. A pending script evaluation could still hang if its frame disappeared; camera and microphone revocations did not yet propagate into Chromium; a host override could still start it with `--no-sandbox`. Those were release-review findings, not solved by the engine picker. The five-day build had produced a way to run Chromium inside Refrax and a way to stop running it. Keeping Chromium current was the next piece of the browser.
