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

Hosting Chrome Extensions on WebKit

~15 min · webkit, extensions, wkwebextension, lifecycle

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

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.uniqueIdentifier is 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 as runtime.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.

Code

cwkVoyager, Sources/VoyagerWebKit/WebKitExtensionHost.swift: loading one package into a context under Chrome's id (verbatim, lines 116-158)·swift
    @discardableResult
    private func load(_ id: String) async -> InstalledExtension? {
        let package = folder.appendingPathComponent(id, isDirectory: true)
        do {
            let manifest = try ExtensionPackage.manifest(at: package)
            try ExtensionPackage.refreshCompatibility(in: package)
            let webExtension = try await WKWebExtension(resourceBaseURL: package)
            let context = WKWebExtensionContext(for: webExtension)
            // The id Chrome gave the package, so its native hosts and its own
            // code (runtime.id) see the extension they expect.
            context.uniqueIdentifier = id
            context.isInspectable = true
            // Dad installed it; what it asks for, it gets. A prompt later is
            // answered the same way.
            for permission in webExtension.requestedPermissions { context.setPermissionStatus(.grantedExplicitly, for: permission) }
            for pattern in webExtension.requestedPermissionMatchPatterns { context.setPermissionStatus(.grantedExplicitly, for: pattern) }
            // Everywhere but the family's own sites (invariant 13).
            applyFamilyAccess(to: context, id: id)
            try controller.load(context)
            contexts[id] = context
            let entry = InstalledExtension(
                id: id, name: manifest.name, version: manifest.version, folder: package,
                hasAction: manifest.hasAction, hasOptionsPage: manifest.hasOptionsPage)
            installed.removeAll { $0.id == id }
            installed.append(entry)
            installed.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
            problems[id] = nil
            if webExtension.hasBackgroundContent {
                // Extension pages can immediately ask their worker for data.
                // Loading the context is not background readiness. Await WebKit's
                // public completion, with a bound for workers that never finish.
                if let failure = await Self.loadBackground(context) {
                    problems[id] = "Background: \(failure)"
                }
            }
            for window in shell?.windows ?? [] { sync(window) }
            shell?.extensionActionsDidChange()
            return entry
        } catch {
            problems[id] = error.localizedDescription
            return nil
        }
    }
cwkVoyager, Sources/VoyagerApp/AppDelegate.swift: the extension host is made before any window (verbatim, lines 135-142)·swift
        let engine = WebKitEngine()
        // The host before any window: a page made after it is in the
        // extensions' reach, one made before is not.
        if let extensions = try? paths.extensionsDirectory() {
            let host = WebKitExtensionHost(folder: extensions, storage: Self.extensionStorage, engine: engine)
            host.shell = self
            extensionHost = host
        }

External links

Exercise

In a small macOS app (macOS 15.4 or later), host an unpacked Manifest V3 extension whose content script sets document.title. Create one WKWebView before you put the WKWebExtensionController on its configuration and one after, load the same page in both, and record which one the content script reaches. Then set the context's uniqueIdentifier to a value of your choice and log chrome.runtime.id from the background to confirm the extension sees it.
Hint
Make the WKWebExtension from the folder with WKWebExtension(resourceBaseURL:), wrap it in a WKWebExtensionContext, and call controller.load(_:). Set configuration.webExtensionController before creating the web view; the view copies its configuration at creation. To read the background's console, set isInspectable on the context and use Safari's Develop menu.

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.