Skip to content

macOS app

A native macOS app under mac/. Swift / SwiftUI for the UI; an embedded helper (BlindProofHelper.app) inside Contents/Library/LoginItems/ that auto-starts at login under launchd via SMAppService and owns the long-running file watcher. The Python sidecar (mac/sidecar/blindproof_sidecar.py) wraps the canonical client crypto module from client/blindproof.py and runs as a child of the helper.

The app does not reimplement the client. The sidecar imports client/blindproof.py via importlib.util.spec_from_file_location so the same extract / capture / SnapshotStore / SnapshotHandler / BackendClient / sync_snapshots code path that the CLI uses is what the Mac app uses too.

The helper captures .md, .txt, and .docx saves (captureSuffixes in BlindProofHelper/main.swift, mirroring the client's SUPPORTED_SUFFIXES). .docx is extracted to text only (approach A) via python-docx, so it requires that dependency in the bundled interpreter — see Python interpreter strategy. Word's ~$-prefixed lock stubs are skipped so autosaves don't generate capture errors.

Bundle layout

BlindProof.app/
├── Contents/
│   ├── MacOS/BlindProof                       # SwiftUI host
│   ├── Library/
│   │   ├── LaunchAgents/
│   │   │   └── org.tomd.blindproof.helper.plist   # launchd descriptor (MachServices: org.tomd.blindproof.helper.xpc)
│   │   └── LoginItems/
│   │       └── BlindProofHelper.app/
│   │           ├── Contents/MacOS/BlindProofHelper
│   │           ├── Contents/Resources/sidecar/    # blindproof_sidecar.py + blindproof.py
│   │           └── Contents/embedded.provisionprofile
│   ├── Resources/
│   │   ├── sidecar/                            # blindproof_sidecar.py + blindproof.py (shared with helper)
│   │   └── Python/                             # bundled python-build-standalone, leaves-first signed
│   │       ├── bin/python3.12
│   │       └── lib/python3.12/site-packages/   # cryptography + argon2-cffi + watchdog + python-docx
│   ├── embedded.provisionprofile               # Developer ID profile authorising keychain-access-groups
│   └── Info.plist

The helper resolves the host's Contents/Resources/Python/ via three parents up from its own bundle URL (Apple's Contents/Library/LoginItems/<helper>.app convention makes that walk contractual). One interpreter, two launch sites.

Two binaries, two embedded provisioning profiles. The host UI carries its own copy of the sidecar so the Auth screen can derive subkeys without round-tripping through the helper.

Targets

Target Role
mac/BlindProof SwiftUI host app. Onboarding state machine, unlock flow, main window, Preferences pane (⌘,) for the Login Item toggle.
mac/BlindProofHelper LSUIElement (no Dock icon) launchd-targeted helper. Watches the configured folder via FSEvents, runs the capture + sync coordinator, hosts the NSXPCListener for Sync now.
mac/BlindProofKit Shared SwiftPM module. State machine, coordinators, NSXPCConnection plumbing, file IO, Keychain wrapper, sidecar client. Pure Swift; no SwiftUI / AppKit imports. Unit-tested under swift test.
mac/sidecar The Python sidecar (blindproof_sidecar.py) and its tests. JSON-RPC over stdio; reuses client/blindproof.py for crypto.

Onboarding state machine

AppScreen.pick(_:) in BlindProofKit is a pure function of persistent + transient state and decides which screen the host UI should be showing:

public static func pick(_ inputs: AppScreenInputs) -> AppScreen
// → .welcome | .newPassphrase | .auth(.signIn|.create)
//   | .unlock | .folderPick | .main

Inputs: presence of identity.json (email + argon2 salt), presence of a backend bearer token in the shared Keychain group, presence of a watch folder in config.json, plus a transient welcomeSeen flag and a pendingNewPassphrase flag the coordinator maintains in memory.

AppCoordinator runs the unlock dance on top: backend login / enrol → sidecar derives subkeys via unlock(passphrase, salt_hex) + export_subkeys(handle) → 64 bytes land in the Keychain under the subkeys account → BackendIdentity (email + salt) and HelperConfig (watch folder) get written. The passphrase itself never leaves the UI process.

Returning authors with a cold Keychain see the Unlock screen — a single passphrase field that re-derives the same subkeys against the saved salt. No backend round-trip.

Disconnect wipes the bearer token + subkey Keychain entries; preserves identity.json and the watch folder so re-linking is one sign-in away.

Pre-unlock state — drop, don't buffer

Because the helper runs at login under launchd and the UI may not be open yet, the helper can be alive and watching the configured folder before the author has unlocked any keys. In that state the helper drops file events. It does not buffer them, does not record cryptographic commitments without the actual snapshot, does not capture in any other half-form. The UI surfaces the state with a "Sign in to start capturing" banner; once the author unlocks, captures begin from that moment forward.

The reasoning: every alternative leaks. Buffering paths and reading them later turns captured_at into a lie if the file changed in between. Recording HMAC commitments without the contents promises evidentiary coverage we cannot deliver. Honest is better than partial. The trade-off — that an author who saves a file in the watched folder before signing in loses that capture — is acceptable because the situation is rare, recoverable (the next save will be captured), and self-evidently signalled in the window.

The rule is enforced by the helper, not by the UI. If the Keychain read fails partway through a session, the helper transitions back to drop-mode immediately rather than continuing on stale state. See the threat model's "What we trust the macOS Keychain with" for the trust trade-off this implies.

Helper ↔ UI communication

Two channels, deliberately split by direction:

  • status.json (1 Hz, helper → UI, state). The helper writes a status snapshot every second to ~/Library/Application Support/BlindProof/status.json: watch folder, capture count, last-capture time, last-sync time + count + error, requiresUnlock flag. The UI's StatusPoller reads it on a 1 Hz timer and updates the main window. One-way; the file is the channel for state.
  • NSXPCConnection on org.tomd.blindproof.helper.xpc (UI → helper, commands). The helper hosts an NSXPCListener whose Mach service name is advertised in its launchd plist's MachServices dict. The UI's Sync now button opens a connection on demand and calls a single BlindProofXPC.syncNow(reply:) method; the helper bridges into CaptureCoordinator.syncIfReady(...) via SyncReplyAdapter.runSync(...) and replies with (count: Int, error: String?). The connection is lazy-created, cached for the app's lifetime, and rebuilt on the next press if the helper restarts.

Generate proof bundle does not go through the helper. The bundle endpoint (POST /api/proof-bundle) only needs the bearer token, which the UI already holds in the shared Keychain group, so the action presents a sheet collecting author / project / attestation, calls BackendClient.requestProofBundle(...) directly, and writes the returned zip to ~/Downloads/BlindProof-<yyyyMMdd>.zip with collision suffixing (BundleFilename.uniquePath).

The XPC client side is gated behind a RemoteSyncing protocol so unit tests use a fake — swift test runs unsigned binaries, and NSXPCConnection to a MachServices name only resolves when the helper is running under launchd. Same pattern that LoginItem uses for SMAppService.

Launch at login

When the author finishes the Auth step (sign in or create account), AppCoordinator registers the embedded BlindProofHelper.app as a launchd-supervised LoginItem via SMAppService.agent(plistName:). The helper's plist (copied into BlindProof.app/Contents/Library/LaunchAgents/) sets KeepAlive=true and ThrottleInterval=10, so launchd brings the helper up at every login and restarts it after a crash with backoff.

Registration is idempotent: the coordinator inspects SMAppService.Status first and only calls register() when the agent isn't already enabled (or awaiting the author's approval in System Settings).

A Preferences pane (⌘,) shows the current status and a toggle. When macOS gates the agent behind requiresApproval, the pane shows a yellow banner with an "Open…" button that deep-links to System Settings → Login Items & Extensions via SMAppService.openSystemSettingsLoginItems().

The whole surface lives behind a LoginItemRegistering protocol in BlindProofKit, which the production LoginItem type and the test fakes both conform to. AppCoordinator and LoginItemController are unit-tested without touching launchd.

Caveat. SMAppService caches the plist contents at first-register time. Re-installing BlindProof.app does not invalidate that cache; the helper keeps running against the old descriptor. If a release changes the launchd plist (e.g. adds a new MachServices entry), the user has to either toggle the helper off → on in the Preferences pane, or fully log out + log in. Re-installing the .app alone is not enough.

A subtler version of the same problem: even when the plist is unchanged, the already-running helper keeps executing the binary it exec'd at launch, so a freshly-installed build doesn't take effect until the process restarts. The versioning below is what lets the app detect that case, and the auto-bounce acts on it.

Versioning

Both bundles carry an explicit, build-stamped version so a bug report can name exactly what's running.

  • CFBundleShortVersionString / CFBundleVersion are set per target in project.yml (0.1.0 / 1 today).
  • A custom BlindProofGitSHA key holds the short git SHA. project.yml defaults it to dev; Scripts/build.sh overrides it with git rev-parse --short HEAD on the xcodebuild line, and Xcode expands $(BLINDPROOF_GIT_SHA) into each bundle's Info.plist at build time. Plain xcodegen / Xcode builds keep dev.
  • BuildInfo (in BlindProofKit) reads those keys from any bundle's infoDictionary and normalises the SHA — dev, the unexpanded $(BLINDPROOF_GIT_SHA) literal, and empty all collapse to "no SHA", so displayString reads 0.1.0 (f4ae0a7) for a release build and 0.1.0 (dev) otherwise.

The git SHA — not CFBundleVersion, which is static across rebuilds — is the identifier that distinguishes one build from the next.

The running helper reports its own version in status.json (helper_version + helper_git_sha), alongside the capture/sync fields the UI already polls. Both are written every tick from BuildInfo(bundle: .main) and decode to nil when absent, so a status file an older helper wrote still reads cleanly.

Settings (⌘,) → Version shows the app version and the running helper's version, read live from status.json and re-checked every second while the pane is open. Because the status file persists after the helper exits — and helper_running is always written true — the pane treats a status whose updated_at is older than a few seconds (WatchStatus.isFresh) as not running, rather than reporting a helper that has gone away. A Copy version details button puts a one-line summary (Diagnostics.supportString) on the clipboard — e.g. BlindProof 0.1.0 (f4ae0a7) · helper 0.1.0 (e3c9627) · macOS 14.5.0 — for pasting into a bug report.

So that a captured log self-identifies — not just the live one — both processes stamp their build into the log stream at startup: the helper logs BlindProofHelper version <ver> (<sha>), and the sidecar logs a sidecar started record carrying the bundled python version and a client capability string (the suffix→extractor-version map, e.g. docx=docx-v1,md=text-v1,txt=text-v1 — the datum that would have made the stale-.docx-bundle incident in #27 obvious in a log).

Diagnostics export

Settings (⌘,) → Export diagnostics… (beside Copy version details) writes a single zip an author can email to support — the richer companion to the one-line clipboard summary. Issue #36.

Tapping it first shows a consent dialog, because for the beta the bundle is not redacted: it names the sensitive details inside (file paths, document names from the watch folder, the account email) and asks the author to share it only with the BlindProof team. The cohort is small and trusted, so consent is the chosen control rather than a redaction layer; redaction is tracked as a production prerequisite on #36. On confirm, a save panel chooses the destination and the gather/zip runs off the main thread (a 24h log show can take a beat).

The zip expands to a single BlindProof-diagnostics/ folder containing:

  • versions.txt — app + helper + sidecar (best-effort bundled-Python) + macOS (Diagnostics.versionsReport).
  • health.txt — a calm summary of what the UI can cheaply know: helper running, login-item state, watch folder + readability, awaiting-unlock, capture count, last capture/sync, last sync error (DiagnosticsHealth). Seeds the future preflight panel.
  • unified.loglog show --predicate 'subsystem == "org.tomd.blindproof"' --last 24h, which captures both the Swift os.Logger lines and the sidecar's JSON stderr (the helper re-emits the latter into the same subsystem).
  • helper-tmp.out.log / helper-tmp.err.log — the launchd stdout/stderr redirects from /tmp, where a hard crash would land. Included when present.
  • status.json / config.json — the helper's current state and configuration. Included when present.

What never appears in the bundle, regardless of the no-redaction stance: manuscript plaintext, snapshot bytes, ciphertext, the store DB, keys, or the passphrase. The assembler (DiagnosticsBundle.assemble) has no parameter to pass them, so content can't leak through this path. The decisions worth testing — which files, in what order; how versions.txt and health.txt read — are pure and unit-tested in BlindProofKit; DiagnosticsExporter (app target) is the thin I/O seam around them (log show, /tmp reads, zip -X, the save panel).

Auto-bounce

The app restarts ("bounces") the helper automatically whenever the running process no longer matches what should be running — so a fresh install or an account switch takes effect without the author touching launchctl or the Login Items toggle.

The mechanism is one XPC verb, BlindProofXPC.restart: the helper first relocks (drops its unlock handle, so it stops syncing and capturing) and only then replies true — so by the time the caller's await returns, the old helper can no longer touch the store. It then runs the same clean shutdown as its SIGTERM path (stop the watcher, drain the sidecar) and exit(0)s. launchd's KeepAlive re-execs it from the on-disk BundleProgram path — which, after an install, is the new binary. This is preferred over re-registering via SMAppService (which can drop the agent into requiresApproval, leaving it not running until the author approves it in System Settings) and over shelling out to launchctl.

Two triggers:

  • Stale binary, on launch. HelperVersionGate (run once from ContentView.onAppear) compares the bundled helper's git SHA — read from BlindProof.app/Contents/Library/LoginItems/BlindProofHelper.app — against the SHA the running helper reports in status.json. It bounces only when the status is fresh (a live helper — see WatchStatus.isFresh) and the SHAs differ. A helper predating the version concept reports no SHA and so counts as stale; a dev build with no bundled SHA never bounces.
  • Account change. On a successful enrol / sign-in, AppCoordinator bounces the helper before it persists the new account's credentials. Because the restart RPC relocks and stops the old helper before it acks, a timer-driven sync in the window can't upload the previous account's pending snapshots under the newly-written token (the helper reads the bearer token fresh on every sync, but only re-derives subkeys on restart — so the order matters). launchd relaunches it against the new keys/store once they're written. Disconnect deliberately does not bounce — the helper relocks on its own, and a restart would only re-launch it into a locked state.

Both paths are best-effort: if the helper isn't running, the XPC call fails harmlessly and launchd starts the current binary fresh on its own.

One-time bootstrap caveat. Because the bounce is an XPC verb, the running helper has to implement restart for it to land. A helper built before this feature shipped doesn't — so the very first upgrade onto an auto-bounce build can't bounce the old helper: the new app detects the version mismatch and calls restart, but there's no method on the old side to receive it, and the old helper keeps running. That single transition needs a manual restart (the fallback below, toggling the Login Item off/on, a re-login, or a reboot); every upgrade after that auto-bounces normally, since the running helper now speaks the verb. There's deliberately no launchctl fallback baked into the gate — with a tiny user base the one-time manual step is acceptable, and it keeps the app from shelling out.

Manual fallback. If the helper's XPC listener is ever wedged — or to perform the one-time bootstrap above — launchctl kickstart -k gui/$(id -u)/org.tomd.blindproof.helper forces the restart from a terminal.

File watching

FSEventsWatcher (in BlindProofKit) wraps FSEventStream over the configured folder. FSEventsWatcher.classify(path:flags:) is a pure function that maps event flags to one of two outcomes:

  • .fileSaved(URL) for ItemModified, ItemCreated, or ItemRenamed. The rename case is the editor "atomic save" pattern (TextEdit, iA Writer, vim and friends save via "write tempfile + rename onto target" for crash safety) — without it, the naïve "ItemModified-only" strategy silently misses every save from those editors.
  • .removed(URL) for ItemRemoved.

The helper routes .fileSaved events through CaptureCoordinator.handle(event:storeDir:), which drops them when the coordinator is locked, otherwise calls capture_path on the sidecar.

Word saves atomically (write temp → rename → metadata update), so a single ⌘S fires several FSEvents inside the watcher's latency window. capture_path content-dedups against the previous snapshot for that path, so only the first event of a save persists a row; the sidecar reports recorded: false for the deduped ones, and handle(...) returns false so the helper advances its capture counter only on a real capture (issue #28).

Tests

swift test --package-path mac/BlindProofKit

160 tests covering the state machine, the coordinators, the XPC reply bridge, the bundle filename collision logic, the FSEvents classifier, the Keychain wrapper, and the sidecar client. Hermetic — no XPC, no launchd, no real Keychain when no access group is set.

pytest mac/sidecar/tests/

26 Python sidecar tests. Hermetic — the sidecar's stdio is driven from the test, no subprocesses spawned.

The two xcodebuild test targets (BlindProofTests, BlindProofHelperTests) are smoke-only and run against a built .app; they're not part of the day-to-day development loop.

Building a release

Distribution shape: a Developer ID-signed, hardened-runtime, notarised, stapled .app, packaged as a signed, notarised, stapled DMG. Four scripts under mac/Scripts/ chain into the release:

cd mac
xcodegen generate          # only needed if project.yml changed
./Scripts/build.sh         # archive + export, signed with Developer ID
./Scripts/sign.sh          # codesign --verify --deep --strict (acceptance)
./Scripts/notarize.sh      # notarytool submit --wait + stapler staple
./Scripts/package.sh       # build/BlindProof.dmg (signed, notarised, stapled)

Why a DMG, not a zip. macOS tags the built .app with a system-protected com.apple.provenance xattr (it can't be stripped — xattr -cr is a no-op on it). ditto encodes that xattr as AppleDouble ._* companion entries inside a zip; ditto and Finder's Archive Utility restore them as real xattrs, but unzip and many third-party unarchivers drop them in as literal junk files inside the bundle, which aren't in the code-signature manifest — so the seal breaks and the recipient gets Finder's "BlindProof is damaged and can't be opened". stapler validate doesn't catch it (it checks the ticket, not the seal), and the developer's own spctl --assess passes because they extract with ditto. A DMG is a filesystem image: the Finder copies the app out byte-for-byte (real xattrs, no AppleDouble), so no recipient unarchiver can corrupt it. package.sh builds the image with hdiutil (not create-dmg, whose Finder-AppleScript window styling hangs headless), signs it, notarises it, and staples it. The .app inside is independently notarised + stapled by notarize.sh, so it also runs offline once dragged to /Applications.

build.sh auto-detects the first Developer ID Application identity in your login keychain — override with BLINDPROOF_SIGNING_IDENTITY=<sha-1-or-cn-substring> if you have more than one. notarize.sh reads credentials from the blindproof-notary keychain profile (override with BLINDPROOF_NOTARY_PROFILE).

Final acceptance: spctl --assess --type execute --verbose=2 mac/build/export/BlindProof.app should report accepted / source=Notarized Developer ID. Move the .app to /Applications, reboot, and confirm pgrep -fl BlindProofHelper shows the helper running before the UI is opened — with no Keychain prompts during the cold-start unlock dance.

Credential prerequisites (one-time per dev machine)

  1. Developer ID Application certificate in the login keychain. Create one at developer.apple.com → Certificates → "+" → "Developer ID Application", download the .cer, and double-click to install. Confirm with security find-identity -v -p codesigning — the line should start with Developer ID Application:.
  2. Notarytool credentials stored as a keychain profile:
    xcrun notarytool store-credentials "blindproof-notary" \
        --apple-id [email protected] \
        --team-id  TEAMID \
        --password APP_SPECIFIC_PASSWORD
    
    Generate the app-specific password at appleid.apple.com → Sign-In and Security → App-Specific Passwords. The team-id is the parenthesised suffix in your Developer ID identity's common name.

Debug builds are unaffected: Configuration: Debug keeps the ad-hoc - signing identity and hardened runtime off, so xcodebuild build from a fresh checkout works on machines without a Developer ID cert. (Code-signing-allowed=NO is needed for Debug because the kernel-restricted keychain-access-groups entitlement still demands a profile even in Debug; see Shared Keychain access group below.)

Shared Keychain access group

The UI (org.tomd.blindproof) and the helper (org.tomd.blindproof.helper) are different signed binaries with different per-app Keychain access groups. Without intervention, the helper's first read of an item the UI wrote (subkeys, backend-token) prompts the macOS Keychain Access dialog, because the helper's signing identity is not on the item's ACL — two items × two processes ≈ four prompts on first unlock, even for a brand-new author.

Both Resources/Entitlements.BlindProof.plist and Resources/Entitlements.BlindProofHelper.plist therefore declare the same keychain-access-groups array containing 8P3BX64C4C.org.tomd.blindproof.shared. Keychain (in BlindProofKit) takes an optional accessGroup init argument and threads kSecAttrAccessGroup into every SecItem* query; the two production stores (KeychainSubkeyStore, KeychainBackendTokenStore) default to BundleIdentifiers.keychainAccessGroup, so all writes land in the shared group and all reads filter to it. Items written by either bundle are silently readable by the other.

The team-id prefix (8P3BX64C4C.) is hard-coded in three places — both entitlements plists and BundleIdentifiers.swift — because Apple does not expand $(AppIdentifierPrefix) at runtime for keychain-access-groups. Codesign rejects any mismatch against the signing identity's team, so a wrong prefix fails fast at sign time rather than at first unlock.

macOS has two keychain backends. The default SecItemAdd writes to the legacy keychain, which attaches a per-process code-signature ACL to every item — so a second process in the same access group still trips the "Allow access" prompt on first read, defeating the entire point of the shared group. To get true silent cross-process access we have to opt into the modern data-protection keychain (iOS-style) by setting kSecUseDataProtectionKeychain = true on every SecItem query; in that backend, access is controlled solely by kSecAttrAccessGroup. Keychain flips this flag automatically whenever an access group is provided, and leaves it off for the no-group path so unsigned swift test runs (which lack the application-identifier entitlement that the data-protection backend requires) keep working.

keychain-access-groups is a kernel-restricted entitlement — AMFI refuses to launch any binary claiming it without an embedded Developer ID provisioning profile that authorises the team-id wildcard. The error is the generic "The application can't be opened" Finder dialog; in the system log AMFI logs restricted entitlements, but the validation of its code signature failed even though codesign --verify and spctl --assess both pass. Two Developer ID provisioning profiles must be present:

  • One per App ID (org.tomd.blindproof, org.tomd.blindproof.helper) created at developer.apple.com → Profiles → Distribution → Developer ID. There is no "Keychain Sharing" capability in the portal; any explicit App ID auto-grants the wildcard. Profiles never expire (Developer ID profiles have ~17-year TTL) but are bound to a specific Developer ID Application certificate.
  • The two .provisionprofile files live in mac/Profiles/ (gitignored — keep them per-developer) under stable filenames BlindProof.provisionprofile and BlindProofHelper.provisionprofile.
  • Each must also be installed in ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ under its UUID-derived filename (e.g. 4aec23b7-…provisionprofile) so xcodebuild can find it. Get the UUID via security cms -D -i <profile> | grep -A1 UUID.
  • mac/project.yml Release config wires PROVISIONING_PROFILE_SPECIFIER per target (the profile name, not UUID).

If you re-clone the repo on a new dev machine, the profiles need to be re-downloaded from the portal, copied into mac/Profiles/, and installed under their UUID name in ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. The next ./Scripts/build.sh will then sign through them, embed Contents/embedded.provisionprofile, and produce a binary AMFI accepts.

If a notarisation suddenly rejects with the opaque "The signature of the binary is invalid" — even though local verification passes — rm -rf mac/build/DerivedData mac/build/export and re-run the chain. Stale codesign output from a prior failed signing attempt produces locally-valid-but-notary-invalid signatures.

Python interpreter strategy

The notarised .app ships three things to make the sidecar self-contained: the sidecar script (mac/sidecar/blindproof_sidecar.py) and the canonical client crypto module (client/blindproof.py) inside Contents/Resources/sidecar/ of both the host UI and the embedded helper, plus a bundled Python interpreter under the host's Contents/Resources/Python/. SidecarLauncher resolves all three from Bundle.main and falls back to the dev walkUp (sidecar/client) and uv run (interpreter) only when the bundled copies aren't present.

One interpreter, two launch sites

The host UI (BlindProof.app/Contents/MacOS/BlindProof) and the embedded helper (BlindProof.app/Contents/Library/LoginItems/BlindProofHelper.app/Contents/MacOS/BlindProofHelper) each spawn Python independently — the UI for the unlock flow's subkey derivation, the helper for the long-running capture/sync coordinator. They share a single interpreter shipped in the host's Contents/Resources/Python/. The helper resolves it via the fixed relative path <helper-bundle>/../../../Contents/Resources/Python/bin/python3.12 — Apple's Contents/Library/LoginItems/<helper>.app convention makes that walk contractual rather than heuristic. SidecarLauncher.discoverPython encodes both layouts so the same logic works from either bundle.

Runtime deps (cryptography, argon2-cffi, watchdog, python-docx — pinned to 1.2.0 to match the client's docx-v1 extractor — plus their transitive deps, notably lxml) are pre-installed at build time into the bundled interpreter's standard lib/python3.12/site-packages directory, so the launcher just spawns <python> <sidecar.py> — no PYTHONPATH plumbing, no uv, no dependency on what's on the user's machine.

Build flow (Scripts/fetch-python.shScripts/build.sh)

  1. Scripts/fetch-python.sh downloads a SHA-pinned python-build-standalone tarball — currently cpython-3.12.13+20260414-aarch64-apple-darwin-install_only.tar.gz — into mac/build/python-cache/ (gitignored), verifies its SHA256, extracts it into mac/build/python-prepared/python/, strips notary-noisy stdlib bits (test, idlelib, turtledemo, tkinter, ensurepip), then pip installs the runtime deps into the bundled interpreter's site-packages. Pinning by SHA (not just by tag) prevents a republished release from silently changing what we ship; bump PBS_VERSION / PBS_TAG / PBS_SHA together at the top of the script when refreshing.
  2. Scripts/build.sh runs fetch-python.sh if python-prepared/ is empty or stale — it compares a checksum of fetch-python.sh (stamped into python-prepared/.fetch-python.sha256 on the last successful run) against the current script, so a dependency or PBS bump regenerates the tree rather than silently reusing one built before the change. Then xcodebuild build produces the .app (with no Python in it yet — Xcode signs the bundle as it stands), then ditto's the prepared Python tree into Contents/Resources/Python/, then Scripts/sign-python-leaves.sh re-signs every nested *.dylib, *.so, and the python3.12 binary itself with the Developer ID identity (deepest first — find -depth), then re-seals the outer host bundle with codesign --force --preserve-metadata=identifier,entitlements,flags so its CodeResources reflects the embedded interpreter.

PBS dylibs ship with ad-hoc signatures and pip-installed wheels (cryptography's _rust.abi3.so, argon2-cffi's _ffi.abi3.so, lxml's etree.cpython-*.so pulled in by python-docx, …) carry no Developer-ID-team signature at all. The hardened runtime's library validation rejects loading any of them at runtime unless they're signed by the same team as the loader. sign-python-leaves.sh handles both cases via codesign --remove-signature before re-signing.

The arm64-only build is acceptable for "send to author friend" reach; a universal tarball is a follow-up if Intel demand materialises (PBS publishes x86_64-apple-darwin builds in the same release).

Why we don't add cs.disable-library-validation

com.apple.security.cs.disable-library-validation is for loading dylibs signed by other Team IDs. Once we re-sign every nested binary in the embedded Python tree with our own identity, hardened runtime is satisfied — no entitlement needed. Adding it reflexively would loosen the security posture for no gain. If notary actually objects, then revisit; the current shape leaves the entitlement absent on purpose.

Dev fallback (BLINDPROOF_PYTHON_PATH)

SidecarLauncher.discoverPython honours an env override at the front of its priority chain: BLINDPROOF_PYTHON_PATH=/path/to/python3.12 wins over the bundled interpreter and the uv run fallback. Use it when iterating on the sidecar without rebuilding the .app, or when running xcodebuild build (Debug) on a checkout that hasn't run fetch-python.sh. When the override is unset and no bundled Python is found, the launcher falls through to the original /usr/bin/env uv run --with cryptography ... invocation — swift test and Debug builds keep working without fetch-python.sh having been run.

BLINDPROOF_SKIP_PYTHON=1 on Scripts/build.sh skips the embed/sign step entirely. The resulting .app falls through to the dev uv run path at runtime — fine for local smoke tests on a non-sidecar change, not for distribution. The Scripts/sign.sh Python check no-ops in that case.