Magic Apps

Documentation

macmagic.app

Releases and updates

Status: Approved specification

A release is a signed, notarised Apple Silicon build that replaces itself in the background. This page covers how it is built, hosted and offered as an update.

Pipeline pieces

Component Implementation What it does
Update engine electron-updater Downloads and installs delta or full updates through the generic provider
Artefact hosting Cloudflare R2 behind a custom domain One bucket with a prefix per app; serving an object costs nothing
Packaging release.yml on the self-hosted runner Runs on the Mac holding the certificate, so no signing secret is stored
Target Apple Silicon only (arm64), macOS 14+ Sonoma through Tahoe

Shape of a release

graph TD
    Trigger["Tag push <app>-v1.2.0<br/>or workflow_dispatch"] --> Runner

    subgraph Runner["release.yml on the self-hosted macOS runner"]
        direction TB
        Version["Stamp version from the tag<br/>(package.json, .appex Info.plists)"]
        Native["build-app.sh: six targets<br/>tsup · SwiftPM ×2 · node-gyp · xcodebuild ×2 · swiftc"]
        SignEx["Each .appex signs itself<br/>Developer ID + --options runtime + --timestamp"]
        Builder["electron-builder: seal the bundle<br/>hardenedRuntime, entitlements, signIgnore"]
        Notary["notarytool submit --wait, then staple"]
        Artifacts["dmg (download) + zip + latest-mac.yml (updater)"]
        Version --> Native --> SignEx --> Builder --> Notary --> Artifacts
    end

    Artifacts -->|"upload"| Bucket[("R2: magic-apps-releases<br/>magimail/ and magical/")]
    Bucket -->|"Custom domain"| CDN["Cloudflare edge: updates.macmagic.app"]
    CDN -->|"GET latest-mac.yml"| Client["electron-updater in Main"]
    Client -->|"newer version available"| Download["download zip → ShipIt swap"]

The update client never consults the licence: an install with no key takes every update it is offered, because the check gates the launch instead; see licensing.md.

Distribution and updates

Bucket and custom domain

One Cloudflare R2 bucket, magic-apps-releases, holds both apps' artefacts, and each app writes under its own prefix: magimail/ and magical/. A custom domain in front of the bucket serves it from Cloudflare's edge. The bucket's r2.dev subdomain stays off, so the custom domain is the only credential-free route in, and the S3 endpoint the release uploads to stays behind an API token.

The client uses electron-builder's generic provider. The publish block is baked into the shipped bundle as Contents/Resources/app-update.yml: the provider pointed at https://updates.macmagic.app/<app>/, with no credentials in the bundle. tools/release.sh uploads the artefacts with the aws CLI pointed at R2's S3 endpoint. The generic provider reads <url>/latest-mac.yml, a fixed path, so the app prefix has to appear in that URL and not only in the object key; two apps published at the bucket root would share one manifest and each release would offer the other app's build as an update.

Upload latest-mac.yml, the .zip, the .dmg and both .blockmap files. The updater reads the manifest and the zip; the blockmaps carry the delta downloads.

Caching for manifests and artefacts

latest-mac.yml must never be stale, because it is the file that says a new version exists. It is also small, plain text and looks eminently cacheable, which is the trap. Everything else carries its version in its own filename and never changes. Cloudflare caches by file extension, and its default list settles this with no rule to write:

Path pattern Cached at the edge Why
*.yml No Not on the default list, so a hotfix is immediate
*.zip, *.dmg Yes On the default list, and the version is in the name
*.blockmap No Not on the default list; small, and read on a delta

So there is no invalidation step and nothing to purge; the manifest is fetched from R2 on every check. tools/release.sh sets --cache-control at upload anyway, 60 seconds on the manifest and max-age=31536000, immutable on the binaries, so the split survives someone later adding a cache-everything rule to the zone.

Cloudflare will not cache an object over 512 MB below the Enterprise plan. The update .zip is roughly 150 MB, so that only becomes a constraint if the bundle triples.

Version numbers are currently 0.0.0, and nothing updates

Both package.json files declare version 0.0.0. electron-updater compares semver, so a customer on 0.0.0 and a release at 0.0.0 never updates, and nothing reports an error because nothing is wrong.

The release workflow's first step derives the version from the git tag and writes it into the app's package.json before build-app.sh runs. The extension build scripts read it from there, so one write reaches every bundle. macOS decides an extension has changed by a separate build number; see process-model-and-ipc.md's Extension build number.

Extensions after an update

ShipIt swaps the whole .app atomically, so Contents/Resources/MagimailHelper, libMagimailNative.dylib, magimail_addon.node and the .appex bundles are all replaced at once. The system re-registers an extension only when it next scans the bundle, so verify these after the first real update:

Clean shutdown on update

sendToHelper() (src/swift-helper.ts) respawns the Swift helper whenever its stdin is not writable, so an uncoordinated SIGTERM can bring the helper back before quitAndInstall() has finished swapping the bundle. Shut down in this order:

  1. Set a shutting-down flag in Main, so sendToHelper() resolves null at once rather than spawning.
  2. Stop the Atom poller so in-flight polling cannot push tray updates.
  3. Call killHelper().
  4. Call autoUpdater.quitAndInstall().

Apple silicon only

electron-builder.json targets arch: ["arm64"], and official builds are Apple Silicon only (arm64, macOS 14+). No Intel (x86_64) build is produced: the native compilation targets require Apple Silicon, and a universal bundle cannot be tested reliably without dedicated Intel hardware. Output filenames keep the architecture suffix (e.g. Magimail-1.2.0-arm64.dmg), and download links state the macOS 14+ Apple Silicon requirement plainly.

Before the first Developer ID build

The signing rules that make a bundle notarisable, and the entitlements it needs, are in building-and-signing.md. The credentials to submit with are what is missing. Two things to check:

  1. Verify nested binaries are sealed. Notarisation refuses the bundle over any one of them and names the requirement rather than the file, so check the whole tree rather than a sample:

    find /Applications/Magimail.app -type f \
        \( -perm -u+x -o -name "*.dylib" -o -name "*.node" \) |
        while read -r f; do
            file "$f" | grep -q Mach-O || continue
            codesign -dvv "$f" 2>&1 | grep -q "flags=0x10000(runtime)" || echo "unhardened: $f"
        done
    

    The parentheses matter: without them -type f binds only to the first test and the walk reports directories. Add mac.binaries entries if electron-builder ever misses one.

  2. Re-check whether a Developer ID build needs an embedded provisioning profile. The widget cache uses an App Group, and that works on a development certificate with no profile at all, which building-and-signing.md records how that was established. Whether Developer ID signing plus notarisation changes it is untested. If it does, the build needs Contents/embedded.provisionprofile and CI needs a further secret. Settle this before the first Developer ID build rather than at notarisation.

Release build pipeline

The release build runs in .github/workflows/release.yml on the self-hosted self-hosted-mac runner, which is the Mac holding the Developer ID certificate, so no signing material is stored as a GitHub secret. The triggers, the tag scheme, what each step does, the runner requirement and the other workflows are in ../developer-guidelines/ci-and-releases.md.

Community and support

The repository is public, so anyone with Xcode, SwiftPM and pnpm can read the source and build it, and a Google-side change can be watched being fixed in the open. The bug report form, .github/ISSUE_TEMPLATE/bug_report.yml, asks for the macOS version, the Apple Silicon chip and the error logs, so a report arrives with enough in it to work from. Help → Reveal Logs in Finder opens the folder so a user can attach the whole set. What the log holds, and what is redacted out of it, is in logging.md.