Measure before you say "WebKit can't"
When a Chrome extension stops under WebKit, the easy sentence is "WebKit cannot run it." Voyager's house sign forbids that sentence until a probe has run. The probe is a diagnostic script. It copies an installed extension, puts a diagnostic layer in front of the copy's background code, and runs it under Apple's extension host. The layer checks every chrome. and browser. member the package's scripts name against what the host actually defines, puts a recording stand-in where a name is missing, and reports what the background touched and where it stopped. It ran on 2026-10-03 against copies of the extensions installed in Dad's Comet, the browser Voyager was replacing.
The result was a short list. A Chrome package differs from what WebKit offers in exactly three ways, and "WebKit cannot run it" was not one of them.
The three kinds
| Kind | What happens | Measured case |
|---|---|---|
| 1. A name is absent | WebKit leaves a namespace or a member undefined; a background that touches it while starting throws and stops there | 1Password's background stopped at chrome.notifications.onClicked. Its package uses five absent namespaces (downloads, idle, management, notifications, privacy) and eight absent members |
| 2. A name is present where Chrome has none | WebKit defines the background-page getters inside a service worker; Chrome's reference marks them foreground-only | One of Dad's extensions read their presence as "this is a background page", compared the page to window and threw. Its worker swallowed the failure, so it reported "started" with its logger and the eleven scripts after it never loaded |
| 3. The manifest holds a pattern WebKit rejects | A file://*/* among the matches of web_accessible_resources | The same extension. It still loaded; removing the pattern cleared the error |
Each kind has a different cure. An absent name needs an answer. A present name needs hiding, and only inside a worker. A rejected pattern needs removing from the manifest. A single fix for "compatibility" would have missed two of the three.
What the probe taught about building a layer
The same run measured how WebKit's chrome object behaves when you try to change it, and three findings shaped the code:
- A new top-level name can be added. An absent namespace can simply be set on
chrome. - A member set on a namespace object the host hands out is lost when WebKit collects that object and makes it again. A member placed before 1Password's bundle evaluated was gone when the bundle read it. Holding a reference to the namespace object kept it. So the layer keeps every object it touched in a
heldlist. - An existing member cannot be redefined in place:
Object.definePropertyreturns and nothing changes. The globalchromeproperty itself is writable, though, so inside a worker the layer replaces the root with aProxythat answersundefinedfor the hidden getters and passes everything else through. The second code block is that proxy. It hides three members,extension.getBackgroundPage,extension.getViewsandruntime.getBackgroundPage, one more than the file's header comment counts.
One more detail lives in a comment worth reading twice. The layer replaces chrome and leaves WebKit's native browser namespace alone, because WebKit's event dispatcher unwraps browser to find registered listeners, and a Proxy cannot be unwrapped.
The prepared copy
An installed package is never Comet's folder. ExtensionPackage.prepare copies it into extensions/<id>/, drops Chrome's _metadata folder, writes voyager-compat.js beside the code, and rewrites the copy's manifest so the layer runs first in every context of the extension's own world:
- a service worker gets a small loader that runs the layer and then the extension's worker, with
importfor a module worker andimportScriptsfor a classic one (the first code block); - the pop-up and options pages get the layer as their first script;
- content scripts in the extension's isolated world get it first, while a script declared in the page's own world (
"world": "MAIN") is the page's business and is left alone; file:patterns are filtered out ofweb_accessible_resources.
A page the manifest never names, such as an app page a desktop app opens directly, gets no loader from the copy, so the engine injects the same layer as a document-start user script when it makes that page. And because rebuilding Voyager cannot update a copied file, every load calls refreshCompatibility, which rewrites only Voyager's layer and refuses to touch a file it does not recognise.
A stand-in is not an implementation
The layer answers an absent name with the plainest true value: idle.queryState says "active", notifications.getPermissionLevel says "denied", downloads search returns nothing. That is enough for a background to start. It is no more than that. 1Password uses idle to lock itself when the Mac goes idle, and a stand-in that always says "active" will never trigger that lock. The evaluation says it outright, and the gap stays a row in the parity contract until idle, notifications and downloads get real owners. An assistant extension that drives pages through debugger and keeps its tabs in tabGroups starts behind the layer and still cannot work, because no stand-in supplies a debugger.