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.
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.
| ad-hoc / unsigned | Apple Development signed | |
|---|---|---|
| Has a real code signature | No | Yes |
| Shows in Safari without the toggle | No | Yes |
| Survives a Safari quit | No | Yes |
| Good for a multi-day dogfood | No | Yes |
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 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.