The browser hosts, the extension runs
A Chrome extension is a package: a manifest, a background service worker, content scripts for pages, perhaps a pop-up and an options page. It runs inside a browser and asks that browser for things all day long: the open tabs, a new window, a pop-up under its toolbar button, a pipe to a desktop app. Chrome answers from its own code. Voyager answers through Apple's WKWebExtension API, which first shipped in macOS 15.4 and iOS 18.4. Those two releases are the platform floor in Voyager's Package.swift for exactly that reason.
WebKit runs the extension's scripts. Whatever the extension asks of its browser, WebKit hands to a delegate the browser writes. In Voyager that delegate is WebKitExtensionHost, in the WebKit child, and the file's first comment draws the line: everything an extension asks for "goes through the parent's ExtensionShell; nothing here opens a window of its own." Windows and tabs belong to the parent in VoyagerCore, and the child only translates. Track 3 teaches that split. Here it decides who opens the tab when an extension calls chrome.tabs.create: the parent's shell, never the host.
If Manifest V3 itself is new to you (service workers, content scripts, host permissions), Pippa Embed Quest, the site's chrome-extensions-quest, teaches it from the extension's side. This track stands on the other side of the same contract, as the browser.
One controller, a context per package
The host builds exactly one WKWebExtensionController for the app. Its configuration carries a fixed identifier, so what the extensions store for themselves survives a relaunch. A test passes no identifier and gets a store that lives only for the run. Each installed package then gets a WKWebExtensionContext, and three settings on that context carry the design.
- The id stays Chrome's.
context.uniqueIdentifieris set to the 32-letter id Chrome gave the package. A desktop app's native-messaging manifest allows extension origins by that id, and the extension's own code reads it back asruntime.id. A fresh id would break both. A package installed from a plain folder has no Chrome id, so it gets one made from its manifest name. - It is inspectable.
isInspectable = true, so Safari's Web Inspector can attach to the background and the extension's pages. - What it asks for, it gets. Every permission and match pattern the manifest requests is granted explicitly at load, and a later prompt is answered the same way. The code's comment gives the reason in three words: Dad installed it. Then the family's own hosts are denied on top of that grant, since a denied pattern wins over a granted one. Track 6 covers that denial.
Extensions stay out of Incognito entirely. A private window's pages are made without the controller, the host never reports a private window to it, and a request to open a private window, or a tab in one, is refused with "Extensions are off in Incognito." The test extensionsCannotEnumerateOrFocusPrivateWindows holds that line.
The host before the first page
One ordering rule matters more than it looks. WebKit reads webExtensionController from a page's configuration when the page is created. A page made before the host exists stays outside every extension's reach for as long as it lives, and nothing assigned later reaches it. Nothing warns you either: the page just loads without its content scripts. So AppDelegate makes the engine, then the host, and only then any window. The second code block is that order, with its reason written beside it.
Loading, one package at a time
At launch the host loads every package folder in its extensions/ folder, sorted by id, one after another. A package that will not load never joins the installed list. Its problem is recorded under its id, launch posts it in the first window as a notice that the extension did not load, and the rest go on. The comment above loadInstalled still says such a package is listed with its problem, but the Settings list draws only installed entries, so it is not. Loading a context is a different event from its background being ready, and an extension page may ask its worker for data the moment it opens. So the host awaits WebKit's public loadBackgroundContent completion, with an eight-second bound for a worker that never finishes. A background that fails is recorded as that extension's problem and blocks no one else; aFailedBackgroundDoesNotBlockOtherExtensions tests exactly that. That extension did load, so Settings lists it with the problem in red beneath its name.
Dad installs from Settings. The Extensions pane lists what Comet has that Voyager does not have yet, under "In Comet, not here yet", each with an Install button, and offers "Install from Folder…" for an unpacked package. Either way the host installs a prepared copy in Voyager's own folder and never touches the original. What that copy carries is the next lesson.
One test for the whole path
A real-WebKit test, aPackageInstallsStartsSpeaksNativelyReachesAPageAndHasAButton, walks the path from end to end with a fixture package: the install; a background that calls a name WebKit leaves out and then opens a tab; a round trip to a tiny echo host over native messaging; a content script that retitles a page made after the host; a toolbar button whose press shows a pop-up; an extension page opened from a chrome-extension:// address; and the removal. Each part could load on its own while the chain still broke between two of them. Whether a host really hosts shows only when one test drives the whole chain.