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.
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
-
NSExtensionPointIdentifieriscom.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. -
NSExtensionPrincipalClassis$(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:
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.