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:
-
Add notarisation. Apple must scan and countersign the build, or Gatekeeper warns on first launch. Add to the
macblock:"notarize": { "teamId": "<YOUR_TEAM_ID>" }and provide
APPLE_API_KEY,APPLE_API_KEY_IDandAPPLE_API_ISSUER(App Store Connect API key) in the environment. -
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
.p12to 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 app resolves the container through the native addon, because only
containerURL(forSecurityApplicationGroupIdentifier:)makescontainermanagerdcreate and bless the directory. Amkdirfrom Node produces a directory the sandboxed extension cannot open. - That call carries no bundle-identity requirement, unlike
widgets_reload_allnext to it inWidgetBridge.swift. WidgetCenter goes through BaseBoard, which checks the calling executable againstCFBundleExecutable; the App Group check looks only at the code signature's entitlements and team prefix. A bare signed binary outside any bundle can create the container. - Both sides must name the identical group, and neither names it in source. The app reads
com.apple.security.application-groupsback out of its own signature throughSecCodeCopySigningInformation(widgets_app_group_identifier); each extension reads the identifier itsbuild.shstamped intoInfo.plistasAppGroupIdentifier, from the same certificate. A constant agrees with one team and silently disagrees with every other.
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.