Skip to content
C.W.K.
Lesson 02 of 05 · published

Three Kinds of Difference, Measured

~15 min · extensions, compatibility, diagnostics, javascript-proxy

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

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

KindWhat happensMeasured case
1. A name is absentWebKit leaves a namespace or a member undefined; a background that touches it while starting throws and stops there1Password'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 noneWebKit defines the background-page getters inside a service worker; Chrome's reference marks them foreground-onlyOne 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 rejectsA file://*/* among the matches of web_accessible_resourcesThe 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 held list.
  • An existing member cannot be redefined in place: Object.defineProperty returns and nothing changes. The global chrome property itself is writable, though, so inside a worker the layer replaces the root with a Proxy that answers undefined for the hidden getters and passes everything else through. The second code block is that proxy. It hides three members, extension.getBackgroundPage, extension.getViews and runtime.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 import for a module worker and importScripts for 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 of web_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.

Code

cwkVoyager, Sources/VoyagerCore/Extensions.swift: the loader that runs the layer before the extension's own worker (verbatim, lines 206-224)·swift
        // The background: a loader that runs the layer, then the extension's own.
        if var background = manifest["background"] as? [String: Any] {
            if let worker = background["service_worker"] as? String {
                let folder = (worker as NSString).deletingLastPathComponent
                let loaderPath = folder.isEmpty ? loaderFile : folder + "/" + loaderFile
                let compatPath = "/" + compatFile
                let loader: String
                if background["type"] as? String == "module" {
                    loader = "import '\(compatPath)';\nimport '/\(worker)';\n"
                } else {
                    loader = "importScripts('\(compatPath)');\nimportScripts('/\(worker)');\n"
                }
                try loader.write(to: destination.appendingPathComponent(loaderPath), atomically: true, encoding: .utf8)
                background["service_worker"] = loaderPath
            } else if let scripts = background["scripts"] as? [String] {
                background["scripts"] = [compatFile] + scripts
            }
            manifest["background"] = background
        }
cwkVoyager, Sources/VoyagerCore/ExtensionCompat.swift: JavaScript inside a Swift raw string, a Proxy over chrome that hides the getters in a worker (verbatim, lines 107-137)·text
      // Inside a worker Chrome has no background-page getters; WebKit defines
      // them, and a package reads their presence as "this is a page".
      if (inWorker) {
        const hidden = { extension: ['getBackgroundPage', 'getViews'], runtime: ['getBackgroundPage'] };
        const wrapped = new Map();
        const root = new Proxy(api, {
          get(target, prop) {
            const value = target[prop];
            if (typeof prop !== 'string' || !hidden[prop] || value == null) return value;
            if (!wrapped.has(prop)) {
              const names = hidden[prop];
              wrapped.set(prop, new Proxy(value, {
                get(inner, member) {
                  if (typeof member === 'string' && names.includes(member)) return undefined;
                  const found = inner[member];
                  return typeof found === 'function' ? found.bind(inner) : found;
                },
                set(inner, member, newValue) { inner[member] = newValue; return true; },
                has(inner, member) { return !(typeof member === 'string' && names.includes(member)) && member in inner; },
              }));
            }
            return wrapped.get(prop);
          },
        });
        try {
          globalThis.chrome = root;
          // Keep WebKit's native browser namespace intact: its event dispatcher
          // unwraps browser (then chrome) to find the registered listeners.
          // A Proxy is not a native namespace and cannot be unwrapped.
        } catch (e) {}
      }

External links

Exercise

Take an unpacked Manifest V3 extension and build a small compatibility report for a second browser. List every chrome.<namespace>.<member> its scripts reference, then load it in the target browser and check from its service worker which of those names are undefined and which names exist that the extension might mistake for something else. Sort what you find into the three kinds from this lesson, and write the cure for each kind before you write any code.
Hint
A regular expression over the package's .js files finds candidate names; filter out ones that only appear for older manifests. In the worker, typeof chrome.x?.y tells you whether a name exists without throwing. A failed importScripts can be swallowed by a classic worker, so log at the top of every script you expect to run.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.