Part 3 of 3 · Signing, loading, and iterating

Making it stick, and iterating on it

Part 2 wrapped the folder in a native app. But an unsigned app will not persist in Safari — and once it does, the day-to-day loop forks into a fast path and a slow one.

The wall behind the wall: code-signing

A native app is not enough — Safari only trusts a signed one across launches.

Part 2 climbed the first wall: Safari makes you wrap the extension folder inside a native macOS app. But building the app is not the end. There is a second wall behind it, quieter and more expensive to discover — how that app is signed decides whether the extension survives a single Safari quit.

An ad-hoc build carries no real code signature. Safari, scanning extensions at launch, sees nothing to trust and ignores it — the com.apple.Safari:Extensions log literally reads “does not have a code signature.” build-wrapper.sh:9–17 The only way to make Safari show an unsigned extension is to flip Develop → Developer Settings → “Allow unsigned extensions” — and that is exactly the trap.

Why ad-hoc can never persist

The “Allow unsigned extensions” toggle resets on every Safari quit (Apple’s “Running your Safari web extension” doc). Safari scans extensions at launch while the box is off and caches the rejection. So an ad-hoc wrapper might survive the afternoon you turned the toggle on, but the next time Safari starts the box is off again and the extension is gone — it can never stay visible across a multi-day dogfooding window.

A real Apple Development signature changes the arithmetic entirely. It gives the extension a code-signing dictionary Safari accepts, so the extension appears without the toggle — nothing to flip, nothing to reset. Development signing is the local-dogfood story, not distribution. Developer-ID-for-distribution is a separate, App-Store-gated path — not what this recipe does. build-wrapper.sh:16–17 That is why the recipe signs with a development identity and never offers an ad-hoc fallback: ad-hoc is not a cheaper option, it is a non-starter for anything that has to last past one launch.

Dev-signed persists
A development-signed wrapper shows in Safari with no toggle, and survives every quit — the only build that holds up across a multi-day dogfooding window.
 ad-hoc / unsignedApple Development signed
Has a real code signatureNoYes
Shows in Safari without the toggleNoYes
Survives a Safari quitNoYes
Good for a multi-day dogfoodNoYes

The prerequisite nobody warns you about

A fresh dev certificate reads as “not valid” until two Apple intermediates are in the keychain.

Settle on development signing and you hit a prerequisite that is almost never documented in the “build a Safari extension” guides. A freshly created Apple Development certificate does not work out of the box: it reads CSSMERR_TP_NOT_TRUSTED and shows as not valid until Apple’s WWDR G3 + G6 intermediates are installed in the keychain. Install them once from Apple’s certificate authority page (AppleWWDRCAG3.cer + AppleWWDRCAG6.cer). build-wrapper.sh:19–26 The check you can run to confirm the chain is sound is security find-identity -v -p codesigning — it must list a valid identity. If it shows one but marked invalid, the WWDR intermediates are what is missing.

With a valid identity in place, the build is a single xcodebuild invocation. The signing arguments are the whole point of the recipe:

xcodebuild -project "$OUT/$APP_NAME/$APP_NAME.xcodeproj" \
  -scheme "$APP_NAME" -configuration Debug \
  -derivedDataPath "$OUT/build" \
  DEVELOPMENT_TEAM="$TEAM" \
  CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="Apple Development" \
  PROVISIONING_PROFILE_SPECIFIER="" build

It is manual signing (CODE_SIGN_STYLE=Manual) with the local CODE_SIGN_IDENTITY="Apple Development" identity and the team id DEVELOPMENT_TEAM=X475L94DE5. build-wrapper.sh:59–64 Note PROVISIONING_PROFILE_SPECIFIER="": no provisioning profile is embedded. macOS runs a development-signed app locally without one, and xcodebuild’s account manager is not reachable headless anyway — so the recipe deliberately leaves it empty rather than fighting the toolchain for a profile it does not need.

Loading & enabling

Run the app once to register it, enable the extension, then grant the localhost host.

The build leaves a .app under wrapper/build/…/Debug/. Getting it into Safari is two manual steps, and the recipe’s closing lines spell them out: build-wrapper.sh:66–69 run the built app once — that is what registers the embedded extension with Safari — then enable “Organon Capture” in Safari’s Settings → Extensions. Because it is dev-signed, there is no Allow-Unsigned toggle to hunt for; the extension is simply there, ready to switch on.

Enabling it is not the last step, though. The extension declares it wants to reach the local receiver — its host_permissions lists http://127.0.0.1/* alongside <all_urls>. manifest.json:7 · declaring a host is a request, not a grant. See the conceptual series’ declaring ≠ getting. But on Safari, declaring a host is a request, not a grant. The flush to 127.0.0.1:17893 stays blocked until the user opens Settings → Websites and sets 127.0.0.1 to Allow.

That manual grant is exactly what the toolbar popup from Part 1 instructs on first run: “Safari blocks the localhost flush until you grant it once… set 127.0.0.1 to Allow, then reload the extension.” popup.html:8–10 The popup we authored back in the public/ folder exists for precisely this moment — it is the in-product reminder that the manifest asked, and the user still has to answer.

The dev loop

REPL-driven development against the running extension — and the two speeds it runs at.

With the extension signed, loaded, and granted, day-to-day work settles into a familiar shape. bb dev watches the dev and test builds, recompiling on every save; bb.edn:4–8 bun out/dev.js then starts the REPL runtime — the :dev build is a :node-script on Bun whose whole job is to give you a live connection to develop against. shadow-cljs.edn:9–12 That is the project’s standing methodology: drive development from the REPL, against the extension as it runs.

But one gotcha lurks in that loop, and it is the MV3 worker-restart rule wearing a new hat. The shared fact-validity gate lives in one .cljc file; the background worker holds it in memory. Add a new fact type and an already-running worker will not know it — it keeps quarantining instances of the type it has never heard of. The background worker must be restarted after a fact type is added, or it quarantines new instances — the shared valid-fact? gate is only as current as the worker that loaded it. The fix is to restart the worker so it reloads the gate — a small ritual, but one that has cost real time when forgotten.

The reason the loop matters so much is that it is not one loop. Touch different things and you pay wildly different costs. Switch between the two modes below and watch the steps light up — the difference between them is the whole argument for keeping logic in ClojureScript:

The fast path is the one you want to live in: a code change is a recompile and a reload, measured in seconds. The slow path is everything Parts 2 and 3 described, run again — regenerate the project, re-sign the app, re-register, re-enable. Keeping the extension’s real logic in ClojureScript is what keeps you on the fast path most of the time; the native wrapper is a thing you build rarely and touch rarelier.

What shipped

Three parts, one extension. Part 1 compiled ClojureScript into the public/ folder a browser could load; Part 2 wrapped that folder in the native app Safari demands; Part 3 made the app persist — a real signature instead of a resetting toggle — and mapped the loop you iterate in once it does.

The journey, complete

The two provenance fact types — :page/visited and :link/clicked — ship and are Safari-proven. The path from ClojureScript source to a signed, persistent, dogfooded Safari extension is complete: compiled, wrapped, signed, loaded, granted, iterating. For what Safari taught us about its behaviour — the platform limits no amount of signing can fix — see the conceptual series’ What Safari taught us.

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:9–17 build-wrapper.sh:19–26 build-wrapper.sh:59–64 build-wrapper.sh:66–69 manifest.json:7 popup.html:8–10 bb.edn:4–8 shadow-cljs.edn:9–12