-
Notifications
You must be signed in to change notification settings - Fork 3
Build and Install
The build-and-install subsystem is the back half of the pipeline: it takes the staged web-extension payload produced by the Conversion Pipeline, wraps it in an Xcode project via Apple's own packager, builds and signs a real macOS/iOS host app + Safari extension (.appex), then moves it into place and registers it with Safari. Everything lives under src/build/:
| File | Responsibility |
|---|---|
src/build/packager.ts |
Xcode project generation, bundle-id patching, native-messaging codegen, build, and built-bundle verification |
src/build/installer.ts |
Move-to-~/Applications, LaunchServices registration, Safari toggle/restart, uninstall, broker LaunchAgent |
src/build/verify.ts |
--verify: read pluginkit to confirm the extension is registered and enabled |
src/build/tempload.ts |
--temp-load: stage-only path for Safari 18's "Add Temporary Extension…" |
Packaging needs a full Xcode install, not just the Command Line Tools. Both xcrun safari-web-extension-packager and xcodebuild ship inside Xcode.app; a bare CLT install has neither.
viaduct --doctor checks the toolchain before you commit to a build (doctor() in src/cli.ts). It probes each dependency and prints a fix hint on failure:
-
xcrun,xcodebuild→ "Requires a full Xcode install (not just CLT)." -
safari-web-extension-packager→ resolved viaxcrun --find. -
plutil,pluginkit,ditto,osascript,lsregister→ part of macOS.
lsregister is not on PATH; its absolute location is pinned in installer.ts as LSREGISTER (/System/Library/Frameworks/CoreServices.framework/…/Support/lsregister).
runPackager() (packager.ts) shells out to Apple's packager with a fixed argument set, --swift --no-open --no-prompt --force, and threads the caller's staged dir, output dir, app name, and bundle id through. Platform selection maps the Platforms type to packager flags:
opts.platforms |
Flag |
|---|---|
macos |
--macos-only |
ios |
--ios-only |
all |
(neither flag, packager emits both) |
The packager writes a host app target plus a Safari-extension .appex target into a generated .xcodeproj. Because a reused output dir can hold a stale project from a prior run (and readdir order is not guaranteed), runPackager prefers the project whose basename is exactly <appName>.xcodeproj before falling back to whatever it finds.
Resource copy vs. symlink. --copy-resources is passed only when opts.copyResources is set. The CLI wires that to --ci (copyResources: values.ci, src/cli.ts); the default is false, which leaves the packager to symlink the staged resources for a fast dev loop. --ci clean-copies them into the project instead, which is what you want for a CI or TestFlight-safe archive.
patchProjectBundleIds() (packager.ts) forces every PRODUCT_BUNDLE_IDENTIFIER in the generated project.pbxproj to the intended value: app targets get bundleId, extension targets get bundleId.Extension. The two rewrites avoid a collision where both targets end up with the same id, the appex regex is written so a user bundle id that itself ends in .Extension isn't rewritten into the appex id on a re-run, which would leave both targets identical (b0c81a1). The same pass grants ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES to every sandboxed target that lacks it, because xcrun only grants it to the app target, without it the appex's URLSession can't resolve any host and the native proxy dies.
The default id comes from defaultBundleId(appName) → com.viaduct.<slug>. The slug strips non-alphanumerics and drops leading digits (a CFBundleIdentifier segment starting with a digit is rejected by parts of Apple's toolchain). It keeps the clean slug only when the slug is a lossless rendering of the name — i.e. the name is already [A-Za-z][A-Za-z0-9]*. Otherwise (the slug dropped or reordered anything: non-ASCII, a hyphen/underscore, a leading digit, all-symbol) it derives a stable per-name suffix from a SHA-1 of the original name. This is what stops two distinct names that reduce to the same non-empty slug — Foo/1Foo, Cafe/Café, MyExt/My-Ext — from collapsing to one id and shadowing each other in LaunchServices; the earlier code only hashed when the slug was entirely empty, so those pairs collided (92fa2ee). Covered by test/derive-app-name.test.js and test/default-bundle-id.test.js.
The app name itself is sanitized upstream by deriveAppName(rawName), which whitelists letters (any script), digits, -, and _ and drops everything else. The name becomes a directory, an xcodebuild scheme, and part of the bundle id, and is passed verbatim to --app-name, which writes it into the generated .xcodeproj XML, so a whitelist (not a blacklist) closes XML / scheme / make-variable injection via characters like < > & " $ \ ( ) | (ba89cc7`).
Patching
project.pbxprojis best-effort. The authoritative check isverifyBuiltBundleId(appPath, bundleId)(packager.ts), which reads the builtInfo.plistof both the app and the compiled.appexand confirms theirCFBundleIdentifiers match intent (e3e3007).This is the check the previous generation lacked: it patched the project but never verified the compiled
.appex, so Safari could end up registering the packager-default id for the wrong extension.verifyBuiltBundleIdhandles both bundle layouts, macOS nests everything underContents/, iOS.apps are flat, resolving theInfo.plistdir per-bundle so iOS builds don't spuriously fail (the old macOS-onlyContents/paths returnednullfor every iOS app, which the caller treats as a fatal mismatch). If it returnsok: false, the build aborts rather than registering the wrong extension with Safari.
Related build-config and iOS hardening: CORS-proxy + build-config hardening (8255ebc) and a batch of iOS-build / crash / codegen fixes from a multi-agent bug hunt (eb71af6).
Apple's packager generates a stub SafariWebExtensionHandler.swift that just echoes messages. writeNativeHandler() (packager.ts) rewrites its beginRequest(with:) to do two real jobs inside the sandboxed appex:
-
HTTP/CORS proxy: for
__c2sProxymessages it performs the request server-side (no browser CORS) and sets the Chrome-extensionOriginheader that Safari forbids JS/DNR from setting. -
Native-messaging client: for
__c2sNMenvelopes it frames the JSON over a loopback TCP connection to a broker running in the (unsandboxed) container app, since the sandbox forbids the appex from exec'ing the host (05cc244).
The allowed hosts, Chrome origin, and broker token are baked into Swift static let literals. Every value is whitelisted to characters valid in a hostname/origin/token before it's spliced in (allowHosts → [a-zA-Z0-9.\-:], origin → adds /, token → [a-zA-Z0-9]), so a malformed manifest can't break out of the Swift string literal (dab04f1). The broker side of this, the loopback server that actually launches the native host, is generated by writeAppBroker() into the container app, and unsandboxAppTarget() drops the app target's sandbox so it can exec the host binary. See Runtime Shim for how the browser-side shim talks to this proxy. Codegen is covered by test/native-messaging-handler.test.js.
buildXcodeProject(xcodeproj, appName, platforms, team?, { teamAutoDetected? }) (packager.ts) picks the scheme, builds Release into a throwaway DerivedData dir under $TMPDIR (never the project tree, an iCloud-synced volume stamps the fresh .appex with file-provider xattrs that make codesign abort), and chooses one of two signing strategies based on whether a team is passed:
Ad-hoc (no --team) |
Team-signed (--team <id> / auto) |
|
|---|---|---|
xcodebuild flags |
CODE_SIGN_IDENTITY=-, CODE_SIGN_STYLE=Manual, empty team/profile, CODE_SIGNING_REQUIRED=NO
|
-allowProvisioningUpdates, CODE_SIGN_STYLE=Automatic, DEVELOPMENT_TEAM=<id>, CODE_SIGN_IDENTITY=Apple Development
|
| Safari requirement | Needs "Allow Unsigned Extensions" toggle | Loads with no toggle |
| Persistence across Safari restart | ✗ toggle resets every Safari session: must re-enable | ✓ survives restarts (1a6ecef) |
| Provisioning profile | none | Xcode creates/refreshes one (App Sandbox entitlement requires it) |
| Typical expiry | n/a | Free personal team ≈ 7 days; paid Developer Program ≈ 1 year |
Both modes sign with the App Sandbox entitlement (the targets set ENABLE_APP_SANDBOX=YES), Safari refuses to register a web-extension appex that lacks it, so CODE_SIGNING_ALLOWED=NO is deliberately not used (it would silently strip the entitlement and the extension would never appear).
Auto team detection. With --team auto (or plain --install) the CLI calls detectXcodeTeam() (packager.ts), which reads three sources and returns a team id you can actually sign with, so you team-sign without ever knowing or typing the id. The team id is the whole job: the build runs xcodebuild -allowProvisioningUpdates, so Xcode mints the development certificate and profile itself.
-
Xcode's cached team list,
IDEProvisioningTeamByIdentifierandIDEProvisioningTeams, read from both thecom.apple.dt.Xcodeandcom.apple.dt.xcodebuilddomains. Which key and which domain get written depends on the Xcode version and on what the user has provisioned, so all four are tried. -
Provisioning profiles on disk,
~/Library/Developer/Xcode/UserData/Provisioning Profilesthen~/Library/MobileDevice/Provisioning Profiles(Xcode 16 moved the directory, so both are read), newest first. Profiles are CMS-signed but the payload plist sits in the blob as plain XML, so the team id is scanned straight out of the bytes rather than shelling out tosecurity cms -Dper file.TeamIdentifieris the modern key; profiles cut before Xcode 6 carry onlyApplicationIdentifierPrefix, and thecom.apple.developer.team-identifierentitlement is read as well. -
The keychain,
security find-identity -v -p codesigning, then the team id out of the certificate's subjectOU. Development identities are preferred (Apple Development,Mac Developer,Apple Distribution,iPhone Developer), thenDeveloper ID Applicationand3rd Party Mac Developer Application: a paid account that has only ever signed notarized releases has nothing else in the keychain, and reading the team off that beats telling the user they have no Apple account. Authoritative when it hits, since the certificate is whatcodesignconsumes.
A team id found on disk is not proof this Mac can sign for it. Profiles outlive the account that installed them, and installers drop profiles for their vendor's team, so a profile-only match hands xcodebuild a team it has no account for and every build dies on error: No Account for Team "…" (#15 auto-detected a third-party vendor's team on a Mac with no Apple account). So only sources 1 and 3 nominate a team, the ones that mean an account or a usable certificate exists; the profiles then choose among those, newest first, which is how the team you most recently provisioned for wins when the keychain holds several. When nothing usable turns up, detection returns null and the build falls back to ad-hoc, announced up front, and a warning rather than a failure at the end. Covered by test/detect-xcode-team.test.js.
The reason for the breadth: a preference cache alone was not enough. Xcode writes it asynchronously and only after it has provisioned something, so an account that was signed in and could sign perfectly well read as "no Apple account" and dropped to ad-hoc, and the remedy the tool printed ("sign in to Xcode") was one the user had already followed (#14). See CLI Reference for --team / --install.
When a detected team still can't sign. Detection can only be as good as what the machine reports: a certificate expires, an account is revoked, a free team hits Apple's app-id limit. When a team that came from detection fails the build for a signing reason (No Account for Team, No signing certificate, requires a development team, No profiles for, a profile that doesn't match), the build is retried ad-hoc rather than throwing the whole conversion away, and returns adHocFallback: true. convert.ts then treats the run as ad-hoc throughout, so the install skips the team-signed path, the "Allow Unsigned Extensions" warning prints, and the signature check expects ad-hoc instead of failing the run. A team the user named with --team <id> is never downgraded this way: that is a deliberate request for a signature that survives Safari quitting, so the run fails and says why. Covered by test/xcodebuild-signing-failure.test.js.
Reading a failed build. xcodebuild splits a failure across both streams: the diagnostics that say why go to stdout, while stderr carries only run-destination noise and ** BUILD FAILED **. viaduct printed stderr and fell back to stdout only when stderr was empty, which it never is, so every signing error was reported as a build failure with no cause, in the terminal and in bug reports alike (#15). xcodebuildDiagnostics() now collects the error: lines from both streams, de-duplicated across targets (xcodebuild repeats each one per target), capped at 20, and falls back to the tail of the raw output when a failure carries no diagnostic at all.
The build returns { builtApp, derivedDir, adHocFallback? } with the app still sitting in DerivedData, the installer moves it in one hop (no copy onto the synced project tree) and then deletes derivedDir, keeping the signature/seal untouched.
setBuildVersion() stamps a unique MARKETING_VERSION / CURRENT_PROJECT_VERSION on every target first, because the packager hardcodes 1.0 (1) and Safari keys its cached copy of the resources on the short version, serving stale JS across reinstalls until the version is bumped.
installToSafari(opts) (installer.ts) makes the extension persist:
-
Move, not copy, the built app to the target dir (default
~/Applications, overridable with--install-dir) viamoveBundle, a same-volume rename is atomic and leaves no duplicate behind, and preserves the signature/seal (bd3bbc7). It does not re-sign: a plaincodesign --sign -would strip the entitlements and Safari would stop registering the appex. -
Register with LaunchServices via
lsregister -f <dest>. -
Ad-hoc only (
!opts.signed && opts.safariRestart): quit Safari,defaults write com.apple.Safari AllowUnsignedAppExtensions -bool true, then relaunch Safari. A team-signed build skips this whole dance because it loads without the toggle.--no-safari-restartalso skips it (gentler mode). -
Launch the host app once (
open <dest>) so Safari actually registers the appex. - Confirm with
pluginkit,bundleRegistered()matches the appex id (<bundleId>.Extension), not a bare substring, so it doesn't report success when only the app (not the extension) appears.
When the extension uses native messaging, installBrokerAgent() (installer.ts, called from convert.ts) writes a per-user LaunchAgent that keeps the container app alive via open -g -W (macOS auto-terminates the idle GUI app, which would kill the broker; launching the binary directly doesn't fire applicationDidFinishLaunching).
Uninstall. uninstallFromSafari(appName, installDir?) unregisters (lsregister -u) before deleting so LaunchServices drops the appex record cleanly, then removes the .app and the broker LaunchAgent. The broker LaunchAgent is keyed on the app's bundle id, which may be a custom --bundle-id rather than com.viaduct.<name>, so uninstall reads the installed bundle's real CFBundleIdentifier off disk (before the rmSync, layout-aware for both macOS-nested and iOS-flat Info.plist) and falls back to the derived default only when that read fails — otherwise a custom-id install left an orphaned KeepAlive agent running open on the deleted app forever (92fa2ee). It guards hard against path traversal and non-.app targets: the resolved path must be a direct child of the install dir and an actual .app directory, so --uninstall "../../../Other" can never rmSync an arbitrary bundle. parsePluginkitList() / listSafariExtensions() back --list, parsing pluginkit -mv output and requiring an absolute path per line so malformed rows are skipped rather than surfaced as garbage.
verifyInSafari(bundleId) (verify.ts) backs --verify (33e9bce). It nudges Safari to scan extensions (open -a Safari), then polls pluginkit for up to ~10s: registration lands asynchronously after Safari launches, so an immediate single query races it and flips the exit code spuriously. It reports two independent facts:
-
Registered:
bundleRegistered()over the verbosepluginkit -mAvvvblock. -
Enabled:
parseEnabled()over the compactpluginkit -mvlisting. The enabled/disabled state is not in the verbose block; it lives in the flag (election) column at the start of each compact line. Perman pluginkit,+/!mean the user elected to use it →true,-means elected to ignore →false, and a blank/=/?column means no election recorded →null(not "disabled"). Older regexes that scanned the verbose block for"enabled"=Nwere dead on real output, and a bare\bdisabled\bscan false-flagged any extension whose path merely contained the word "disabled".
A null enabled state means "registered, couldn't confirm enabled", best-effort, not failure. Covered by test/verify.test.js. See Testing and Debugging for the full reinstall cycle.
writeTempLoadInstructions(stagedDir) (tempload.ts) backs --temp-load: it stages only: no Xcode project, no build, no signing, and drops a SAFARI_LOAD_INSTRUCTIONS.md next to (not inside) the staged dir, so the doc doesn't pollute the folder Safari loads or a --zip archive. This targets Safari 18's Develop → "Add Temporary Extension…", which loads an unpacked extension folder directly.
It's the fastest iteration loop: no compile at all, with one catch: temporary extensions must be re-added after every Safari restart (and still need "Allow Unsigned Extensions", which also resets each restart). Use it while iterating on the shim; switch to --install --team once you want persistence.
- Conversion Pipeline, produces the staged payload this subsystem packages
-
CLI Reference,
--install,--team,--verify,--temp-load,--uninstall,--install-dir,--no-safari-restart,--ci - Runtime Shim, the browser side of the native HTTP/CORS proxy
- Testing and Debugging, the reinstall cycle and stale-cache pitfalls
- Limitations and FAQ, signing expiry and unsigned-toggle resets
Viaduct CLI · @magicelk235/viaduct · PolyForm Shield 1.0.0 · Verified against src/ and grounded in git history.