Magic Apps

Documentation

macmagic.app

Code signing and local builds

scripts/build-app.sh builds all six artefacts, signs each with one certificate, and refuses to package a bundle with any of them missing. On Apple Silicon a binary with no signature does not run, and an app extension whose team differs from its host does not load, so a build with no certificate fails rather than producing a bundle that looks complete and does nothing.

Building

pnpm magimail:install      # build everything and replace /Applications/Magimail.app
pnpm magimail              # build only, into apps/magimail/release/

Or, from apps/magimail/:

./scripts/build-app.sh --install         # full build + install
./scripts/build-app.sh --install --fast  # skip Swift/native/extension rebuilds
./scripts/build-app.sh --help

--install quits a running Magimail first and moves the old bundle to /tmp/Magimail-rollback-<timestamp>.app. Accounts, settings and Gmail sessions live in ~/Library/Application Support/Magimail, so replacing the bundle never touches them.

--fast reuses the existing Swift helper, native dylib, N-API addon and the Share and Widget extensions, which skips roughly two minutes of SwiftPM, node-gyp and xcodebuild work. Use it when only TypeScript changed; drop it whenever anything under swift/, native/, share-extension/ or widgets/ changed.

Even with --fast a packaged build takes ~2.5 minutes, because electron-builder re-extracts the Electron runtime and re-runs @electron/rebuild every time. For iterating on TypeScript, use pnpm dev (tsup --watch plus electron .), which reloads in seconds. A packaged build verifies the real bundle: the native helpers, the Share extension, the signature.

Build script checks every artefact exists

Magimail is five build systems producing six artefacts: tsup (Electron main), SwiftPM (swift/ helper and native/ dylib), node-gyp (N-API addon), xcodebuild (the Share and Widget extensions), and a bare swiftc invocation (the App Intents extension, which has no Xcode project of its own). electron-builder.json bundles them all through extraResources/extraFiles, and when one of those files is missing electron-builder only warns, then packages anyway:

• file source doesn't exist  from=.../swift/.build/release/MagimailHelper

The result launches with no toolbar, no tray, no settings window and no notifications. A missing MagimailWidgets.appex leaves nothing in the widget gallery at all, which is easy to read as a broken widget rather than an absent one. The script builds all six and then fails the build if any artefact is absent, so that bundle cannot be produced by accident.

It checks the artefacts twice. Before packaging, every artefact must exist on disk. After electron-builder returns, every to: destination in electron-builder.json must exist inside the packaged .app. A file copied nowhere and a file copied to the wrong place leave the same working build and the same dead feature, and which directory a file lands in decides whether macOS honours it: the helper takes the app's bundle identity from Contents/MacOS, and an .appex outside Contents/PlugIns is absent from the gallery rather than broken in it. The second list is written out rather than read back from electron-builder.json, so a to: aimed at the wrong directory is caught instead of followed.

Magical's own script checks the same two ways over a shorter list: the Electron main, MagicalHelper, the toolbar dylib, the N-API addon and MagicalAppIntents.appex. It has no Share or Widget extension, and its App Intents extension is built by the same bare swiftc invocation as Magimail's.

Swift helper signs with the app's bundle id

swift/build.sh signs MagimailHelper with --identifier com.alexhaslam.magimail, the app's bundle id rather than the identifier codesign would derive from the filename. mac.signIgnore then stops electron-builder re-signing it back.

Notifications depend on this. usernoted checks the signing identifier of the process asking to post a notification against the bundle it claims to be, and the helper claims Magimail.app because it ships in Contents/MacOS. Signed as MagimailHelper, requestAuthorization fails with UNErrorDomain Code=1. The app never appears in System Settings → Notifications, no banner is ever delivered, and nothing in the build output suggests anything is wrong. See notifications.md's Bundle identity.

Three grades of signature

On Apple Silicon every binary must carry some signature or the kernel refuses to run it.

Grade Proves Result
Ad-hoc (codesign -s -) Nothing about the author; only detects tamper Runs locally; Gatekeeper blocks it if ever quarantined
Apple Development (free account) Built by you, for your own devices Runs on this Mac; will not open on someone else's
Developer ID Application (paid) Apple-verified named developer Runs anywhere with no warnings, once notarised

The build script picks the best identity it finds: Developer ID Application, then Apple Distribution, then Apple Development, then Mac Developer. Override it with --identity "<name>" or MAGIMAIL_SIGN_IDENTITY (MAGICAL_SIGN_IDENTITY for Magical). An override naming a certificate that is not installed is reported and ignored rather than trusted; trusting it fails minutes later inside codesign, with an error that mentions neither the variable nor the fact that the name was never checked.

../../tools/signing.sh resolves the identity, and every script that signs anything calls it: build-app.sh, swift/build.sh and each extension's build.sh. The resolved identity signs the app bundle, the Electron framework, the dylib, the addon, the helper and every extension, so the app and everything inside it carry one team. macOS validates an extension against the host app's signature, so a team mismatch means the extension does not load, with no message from macOS.

Each extension's build.sh also stamps the version from apps/magimail/package.json onto its .appex before signing, so no extension claims a version its host does not. The checked-in Info.plist values are only defaults.

With no certificate at all, the build stops. An ad-hoc signature has no team, so every .appex fails to load, not one in particular: for Magimail that is the Share Sheet entry, the widgets and the Focus Filter, all three, together. The Swift helper is not an extension and does run, but notification authorisation is tied to a stable signature that an ad-hoc build does not have, so each rebuild loses the permissions already granted. Pass --adhoc to build one deliberately, which is useful for working on the Electron side without an Apple account, and which carries down to every script the build shells out to.

mac.identity is not set in electron-builder.json. The script passes the identity through electron-builder's CSC_NAME, or sets CSC_IDENTITY_AUTO_DISCOVERY=false to skip signing, so swapping certificates is a one-variable change.

App Intents extension keeps its own signature

electron-builder re-signs nested bundles it finds inside the app, applying the host's inherited entitlements. For MagimailAppIntents.appex that is fatal: it replaces com.apple.security.app-sandbox with com.apple.security.inherit. inherit only means anything for a child of a sandboxed parent, and the Electron container is not sandboxed, so the extension ends up with no sandbox at all. ExtensionKit refuses to launch an extension in that state, and the Focus UI reports only "Could not load Focus Filter"; nothing names the entitlement.

electron-builder.json therefore carries:

"signIgnore": ["Contents/Extensions/MagimailAppIntents\\.appex"]

so the signature applied by app-intents-extension/build.sh survives packaging. The outer app signature still seals the appex, so the bundle stays valid.

The Share and Widget extensions do not need this. They live in Contents/PlugIns, and electron-builder leaves their entitlements intact there; only the Contents/Extensions bundle gets rewritten. Magical carries the same entry for MagicalAppIntents.appex, for the same reason.

App Intents extension needs an explicit entry point

Both apps' app-intents-extension/build.sh compiles with a bare swiftc rather than xcodebuild, so nothing sets the extension entry point for it. An .appex is launched by the system and must hand control to Foundation's extension host loop. Without -e _NSExtensionMain, swiftc emits its own _main, which returns immediately: the process exits 0 before the host connects, and the Focus UI again reports only "Could not load Focus Filter". Xcode passes this flag for every extension target, which is why the Share and Widget extensions never hit it.

Hardened runtime

Both apps sign every Mach-O they ship with --options runtime --timestamp, because notarisation refuses a bundle holding one without either, and the rejection names the requirement rather than the file that broke it. sign_hardened in tools/signing.sh owns both options, so the helper, the dylib, the addon and each extension cannot be signed without them. --timestamp needs the network and a real certificate; under --sign - codesign ignores it instead of failing, so an ad-hoc build needs no separate path, while offline with a certificate the build stops rather than producing something Apple would reject.

electron-builder.json carries the other half, since electron-builder signs the app bundle, the Electron framework, the dylib and the addon itself:

"hardenedRuntime": true,
"entitlements": "build/.generated/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.inherit.plist"

The first path is the copy build-app.sh generates with the team resolved; it is not a tracked file. Its template is build/entitlements.mac.plist.in, and the .in suffix matters, because electron-builder discovers build/entitlements.mac.plist by convention and hands it straight to the signer. A template sitting at that exact path is picked up whether or not it has been resolved. An unresolved <TEAM_ID> is not well-formed XML, and packaging fails with Opening and ending tag mismatch: "TEAM_ID" != "string"; that only happens once a certificate exists, because with none, signing is skipped and the file is never parsed. Keeping the template off the discovered path is what stops a half-resolved file reaching codesign.

Both plists grant what Electron needs (allow-jit, allow-unsigned-executable-memory), and the app plist also claims the App Group. Without that claim containerURL(forSecurityApplicationGroupIdentifier:) returns nil, the app falls back to reaching into ~/Library/Group Containers/ by path as a process entitled for nothing, and macOS asks the user for access to other apps' data. A keychain access group answers differently: macOS kills a bundle claiming one at exec unless the bundle also embeds a provisioning profile issued by Apple, so nothing in either app shares a keychain item between the app and its extensions; see sessions-and-security.md's Local cache files at rest.

The App Group prefix resolves from the certificate. The Team ID is read from the signing certificate's OU field and substituted into <TEAM_ID>.group.com.alexhaslam.magimail at build time. It comes from the certificate rather than from configuration, because a team ID only means anything as the team of the certificate doing the signing; one that disagrees with it produces entitlements macOS rejects. APPLE_TEAM_ID in the environment is ignored, with a warning. Where no team is resolved the App Group entitlement is dropped rather than left dangling, since an unauthorised group claim is refused outright.

The substituted plist is generated under build/.generated/ and is gitignored, so a run that dies mid-build cannot leave a resolved team ID in the tracked plist.

Moving to developer ID

Once you enrol in the Apple Developer Program and install a Developer ID Application certificate, the build script detects and uses it with no changes, and the signature it produces is already what notarisation wants. Two things are left:

  1. Add notarisation. Apple must scan and countersign the build, or Gatekeeper warns on first launch. Add to the mac block:

    "notarize": { "teamId": "<YOUR_TEAM_ID>" }
    

    and provide APPLE_API_KEY, APPLE_API_KEY_ID and APPLE_API_ISSUER (App Store Connect API key) in the environment.

  2. Keep the signing certificates in the local macOS Keychain. Official release packaging and signing run on one dedicated local macOS machine, where the Developer ID certificates and the notarisation credentials stay resident in the Keychain. Nothing exports a raw .p12 to a third-party CI runner.

Widget cache and the App Group

The widget snapshot lives in ~/Library/Group Containers/<TEAM_ID>.group.com.alexhaslam.magimail/widget-cache.json, which both the app and the widget extension are entitled for. App Groups need no provisioning profile, and so no Developer Program enrolment. Three things make it work on a plain development certificate:

The extension therefore holds no com.apple.security.temporary-exception.files.home-relative-path.read-only. That entitlement granted it read access to the whole of ~/Library/Application Support/Magimail/, session cookies in Partitions/ included, so that it could read one JSON file.

An ad-hoc-signed build carries no team identifier and cannot claim the group. In that case the app falls back to Application Support and the widget renders its "Open Magimail to sync" placeholder, which is correct rather than broken.

App Intents extensions name files, not directories

Neither App Intents extension can use the group for its state, because the Focus Filter's own perform() has to leave something the Swift helper reads where the helper reads it. Both therefore keep temporary-exception.files.home-relative-path entitlements, and both name files: accounts.json read-only for the account picker, focus-filter.json read-write for the state a Focus writes. Their logs do go in the group, as every other extension's does, which is why neither needs a directory grant over ~/Library/Logs/.

A path with no trailing slash names one file, and the sandbox honours it for a file that does not exist yet and for an atomic write, which Data.write(options: .atomic) performs by writing a temporary file and renaming it. A sibling in the same directory is refused with NSPOSIXErrorDomain Code=1. The directory form of the same entitlement also reaches Partitions/ and the session cookies, to read one file and write another.

Verify a change here by confirming that no deny for the container appears while the app polls, and that the widget renders counts. The extension caches its binary, so run killall MagimailWidgets after installing, or it keeps running the old one.

Packaging in CI

The packaging jobs go through this script, so a bundle missing the Swift helper, the native addon or an extension fails the job instead of packaging with a warning and reporting success. build.yml packages both apps' unpacked bundles on every push to main, and release.yml runs the script with --dist through each app's scripts/release.sh, which adds the .dmg, the updater .zip and the blockmaps. Both run on the self-hosted-mac runner, where the Swift toolchain and a full Xcode are installed. The workflows, their triggers and what each step does are in ../developer-guidelines/ci-and-releases.md, and the release pipeline itself is in releases-and-updates.md's Release build pipeline.

Compile-only extension check

check-swift in ci.yml runs each extension's own build.sh with MAGIC_SUITE_COMPILE_ONLY=1, which stops the script once the .appex is assembled and before it stamps a bundle version and signs. Stamping needs node and signing needs a certificate in an unlocked keychain, and neither answers the only question a pull request is asking, which is whether the Swift still compiles. The .appex left in .build/release is unsigned and must not be installed; build-app.sh rebuilds and signs it before packaging. Running the real script rather than a bare xcodebuild keeps the two compiles the same compile.