Shipping updates to a Mac app without the App Store
GHZ Code and GHZ Voice are Mac apps built with Tauri, sold on our site and not on the App Store. For their first months, every fix meant asking every user to download a new DMG and reinstall. After the memory leak in GHZ Voice, we decided the next manual install would be the last one.
This is the full chain we run today, from a commit to an app that updates itself in about nine seconds.
Two artifacts per release
Every release produces two things, for two different audiences:
- A DMG, notarized by Apple, for new users who download the app from their account page.
- A .app.tar.gz plus a signature file, for apps already installed. This is what the updater downloads.
The second signature has nothing to do with Apple. Tauri’s updater checks it against a public key compiled into the app, so a compromised download server cannot push a fake update.
Signing and notarizing
The build signs the app with our Developer ID automatically. It does not notarize, so we do that step ourselves with Apple’s tool and a stored keychain profile, then staple the ticket:
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/<updater-key>)"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
npx tauri build # DMG + .app.tar.gz + .sig
xcrun notarytool submit "GHZ Code.dmg" --keychain-profile <profile> --wait
xcrun stapler staple "GHZ Code.dmg"
shasum -a 256 "GHZ Code.dmg"Two details cost us time. The updater key had no password, and the build still waited for one on a terminal prompt until we set the password variable to an empty string explicitly. And we rebuild the archive from the stapled app, then re-sign it, so the notarization ticket travels with the update.
Our own update server
The files live on our server, named by their SHA-256 hash. A releases table in Postgres says which version is current for each app and platform, with the download URL, the updater URL and the signature.
The app asks one endpoint at startup, with its current version in the path:
GET /updates/tauri/ghz_code/darwin-aarch64/0.3.13
200 → { "version": "0.3.14", "notes": "...", "pub_date": "...",
"platforms": { "darwin-aarch64": { "url": "...", "signature": "..." } } }
204 → you are up to datePublishing a release is one database row. Rolling back is flipping is_latest back on the previous row. The website reads the same table to show download links, so nothing else changes.
The first real update
We published GHZ Voice 0.1.18 only to test the chain. The installed 0.1.17 found it at launch, downloaded it, verified the signature, installed it and relaunched:
- ~9 s
- from launch to updated app
- 3 s
- fastest staged update we timed (Voice 0.1.21 → 0.1.24)
- 1 row
- to publish or roll back a release