Part 2 of 3 · The wall Safari adds

Safari makes you ship an app

A Chromium browser loads the folder Part 1 produced. Safari refuses — it makes you wrap that folder inside a native macOS app first.

Chrome loads the folder; Safari does not

The same public/ folder, two completely different loading models.

Part 1 ended with a complete, unpacked extension: the public/ folder, the manifest and two compiled bundles sitting right there on disk. A Chromium browser will load that folder directly — point Chrome at public/ and the extension runs, no build, no wrapper, no ceremony.

Safari will not. There is no “load this folder” affordance for a web extension on Safari. Apple’s distribution and signing model treats a Safari web extension as something that must be embedded inside a native macOS (or iOS) app bundle: the extension ships as a component of an app, and the app is what the system installs, signs, and trusts. This is the wall the rest of the page is about: the unit of distribution on Safari is an app, not a folder. Everything that follows — the converter, the two targets, the Swift, the Info.plist — is machinery for satisfying that one requirement. So the same folder that Chrome would just run becomes, on Safari, the input to a conversion step that emits an Xcode project. That project, built and signed, is the thing Safari actually loads. This page is the wall and the climb over it.

One command, two targets

Apple’s converter turns the folder into an Xcode project — an app and an extension.

The bridge from folder to app is a single command Apple ships with Xcode: safari-web-extension-converter. The recipe points it at public/ and asks for a macOS-only project:

xcrun safari-web-extension-converter public \
  --project-location wrapper \
  --app-name OrganonCapture \
  --bundle-identifier com.exidia.organon \
  --macos-only --no-open --no-prompt --force

That one invocation build-wrapper.sh:42–46 reads the unpacked extension and writes a full Xcode project under wrapper/. --macos-only drops the iOS targets; --no-open --no-prompt keeps it headless; --force overwrites a previous generation. The project it produces is not one thing but two build targets — a host app and the extension nested inside it. Click through them:

 

The host app (OrganonCapture) is a thin macOS app: a window that reports whether the extension is on and offers to open Safari’s settings. Its code is ViewController.swift. ViewController.swift:14–26 The extension (OrganonCapture Extension) is the real payload: it carries the web extension (the manifest and the compiled bundles from Part 1) plus a small native handler, SafariWebExtensionHandler.swift, declared as the extension’s entry point in Info.plist. Extension/Info.plist:5–11 The app is the container Safari can install; the extension target, nested inside it, is what Safari actually enables.

The Swift you barely write

The converter generates the Swift. For a pure web extension you rarely touch it.

It is worth being honest about the Swift, because the “native app” framing oversells it. The converter generates the Swift for both targets, and for a web extension whose logic lives entirely in ClojureScript, you rarely edit a line of it. There are two files, and both are template output.

SafariWebExtensionHandler.swift is the extension’s native entry point — the principal class Safari instantiates when the web extension sends a native message. The generated implementation is an echo handler: beginRequest pulls the message out of the extension context and completes the request with ["echo": message]. SafariWebExtensionHandler.swift:11–40 It is a stub demonstrating the native-messaging round trip, nothing more.

ViewController.swift is the host window’s controller. When its WKWebView finishes loading, it calls getStateOfSafariExtension to ask whether the extension is enabled, and a message handler calls showPreferencesForExtension to open Safari’s extension preferences when the window’s button is clicked. ViewController.swift:28–55 Useful as an on/off indicator; not a place application logic lives.

And here is the key admission for this project: organon routes its captured facts through the localhost receiver, not through this native bridge. The background worker POSTs Transit to 127.0.0.1:17893 (Part 3’s subject); it never sends a browser.runtime.sendNativeMessage, so the echo handler is never called. For organon, the generated Swift is mostly inert — a wall we must build to satisfy Safari, behind which almost nothing runs.

Read the bridge line by line

Step inside SafariWebExtensionHandler.swift — a line-by-line Swift read of the principal class, the availability branches, and why the echo handler is shaped the way it is: interiors/native-bridge.html →

The extension point

How Info.plist tells Safari “this target is a web extension, and here is its Swift entry.”

What makes the extension target a Safari web extension — rather than some other kind of app extension — is two keys in its Info.plist, both under an NSExtension dict. Extension/Info.plist:5–11

  • NSExtensionPointIdentifier is com.apple.Safari.web-extension — the system extension point that declares “this is a Safari web extension,” which is how Safari knows to surface it in its Extensions settings at all.
  • NSExtensionPrincipalClass is $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler — the Swift class Safari instantiates for native messages. The $(PRODUCT_MODULE_NAME) build variable resolves to the target’s module name, so the value names exactly the handler from the previous section.

So the Info.plist is the contract between Safari and the target: one key declares the kind of extension, the other names the Swift entry point. The manifest from Part 1 describes the web extension to the browser engine; this plist describes the target to macOS.

A real build gotcha: the bundle-id rewrite

The generated project does not build as-is — the recipe encodes the fix.

The converter does not always hand you a build-ready project, and this recipe carries the scar to prove it. Asked for --bundle-identifier com.exidia.organon, the converter (Xcode 26.5) assigns the two targets inconsistent identifiers:

Why the fresh project fails to build

The converter names the app target com.exidia.OrganonCapture (parent prefix + app name) but the embedded extension target com.exidia.organon.Extension (the full requested id + .Extension). Xcode rejects the build, because an embedded app extension’s bundle id must be prefixed by its host app’s id — and com.exidia.OrganonCapture is not a prefix of com.exidia.organon.Extension. build-wrapper.sh:48–57

The fix in build-wrapper.sh is a one-line sed: rewrite the app id from the converter’s derived com.exidia.OrganonCapture back to the requested com.exidia.organon in the generated project.pbxproj. Now the app id does prefix the (already-correct) extension id — com.exidia.organon prefixes com.exidia.organon.Extension — and the build proceeds.

The lesson generalises past this one quirk: the project the converter emits is a starting point, not a finished artefact. A repeatable build recipe has to encode whatever post-generation fixups the toolchain version of the day requires — here, a single substitution that turns a rejected project into a building one.

Grounded in the organon Safari-capture extension, June 2026. Citations are file:line into the real source.

Citation colour marks the source — extension the extension’s own code, build / native the build-tool output and native side.

build-wrapper.sh:42–46 build-wrapper.sh:48–57 ViewController.swift:14–55 SafariWebExtensionHandler.swift:11–40 Extension/Info.plist:5–11