Skip to content
Codebiy

Web Development

Shipping Auto-Updates for a SwiftUI Menu Bar App with Sparkle

A macOS app distributed outside the App Store has to update itself. Here is what that actually took for Wharfy — signing keys, an appcast, and three mistakes we made first.

By codebiy Team · Published · 9 min read

Wharfy ships outside the Mac App Store. That buys us a lot — no review queue, no sandbox fighting the fact that the app's entire job is inspecting other processes' sockets — and it costs us one thing: nobody updates the app for us.

This is what wiring that up actually took.

Why not the App Store

Wharfy lists every process listening on a development port and shows which project and which AI agent started it. Reading another process's open sockets is exactly what the App Store sandbox exists to prevent. There was no version of this app that could ship there, so the decision made itself.

Outside the store, the platform gives you notarization and nothing else. Notarization proves the binary is not malware. It says nothing about how version 0.1.5 reaches someone who downloaded 0.1.2 four weeks ago.

Sparkle in one paragraph

Sparkle is the update framework almost every non-store Mac app uses. The model is simple: you host an XML feed — the appcast — listing your releases. The app polls it, compares versions, downloads the new build, verifies a signature, and swaps itself out on quit. It has been doing this since 2006, and the 2.x line is a genuine rewrite rather than a maintained relic.

The signing key is the whole security model

Sparkle 2 signs updates with EdDSA. You generate a key pair once:

./bin/generate_keys

The private key goes into your login keychain. The public key goes into your Info.plist as SUPublicEDKey. Every appcast entry carries an edSignature of the download, and the app refuses anything that does not verify against the embedded public key.

The consequence is worth stating plainly: whoever holds that private key can push code to every installed copy of your app. Not "can serve a bad download" — can execute code on your users' machines, signed by you, through a path they trust precisely because you told them to. It is a more dangerous secret than your notarization credentials, which only sign what you hand Apple.

Ours is not in the repository, not in CI, and not in a password manager shared with anything else.

The appcast

An appcast is an RSS feed with a Sparkle namespace. One entry per release:

<item>
  <title>0.1.5</title>
  <sparkle:version>15</sparkle:version>
  <sparkle:shortVersionString>0.1.5</sparkle:shortVersionString>
  <sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
  <enclosure
    url="https://wharfy.dev/releases/Wharfy-0.1.5.dmg"
    sparkle:edSignature="…"
    length="8123456"
    type="application/octet-stream" />
</item>

Two fields cause most of the confusion. sparkle:version is the build number — a monotonically increasing integer, the only thing Sparkle actually compares. shortVersionString is the human-facing "0.1.5" and is compared by nobody. Get these backwards and updates either never appear or appear forever.

minimumSystemVersion is the one people forget. Without it, a user on an older macOS is offered a build that will not launch, and their working install is replaced by one that crashes on open.

Three things that went wrong

The DMG was notarized, the app inside it was not stapled. Notarization and stapling are separate steps. Notarizing tells Apple about the build; stapling attaches the resulting ticket to the artifact so Gatekeeper can verify it offline. A DMG that is notarized but not stapled works on the machine that built it — which has the ticket cached — and shows a scary dialog on a fresh Mac. It is a bug you cannot see from your own laptop, which is the worst kind.

The appcast was served with the wrong content type. Our static host guessed text/html for a file ending in .xml. Sparkle's parser failed quietly and the app simply reported no updates available. There is no error dialog for this — it looks exactly like being up to date. We now assert the response headers in CI, not just the file contents.

The update ran while the menu bar popover was open. A menu bar app has no window to close, so Sparkle's usual "quit and relaunch" flow has nothing to hang the restart on. The first release replaced the binary underneath a popover that was still on screen; the app relaunched behind it and the user was left with a dead panel floating over a live app. We now dismiss the popover before handing control to Sparkle.

That last one is the general lesson: Sparkle's defaults assume a document app with windows. A menu bar app is a different animal, and every assumption about the app's lifecycle needs re-checking.

Releasing

The release is one command. It builds, signs, notarizes, staples, generates the edSignature, prepends the appcast entry, and uploads. This is not gold-plating — a release process with manual steps is a release process that gets skipped, and a Mac app that stops shipping updates is one CVE away from being a liability on its users' machines.

The CLI half of Wharfy goes to npm through OIDC trusted publishing, which removes the long-lived token from the equation entirely. That is a separate post.

Worth it?

For any app you plan to maintain, yes, and it is not close. The alternative is a download page and a hope that people check it. Two days of work bought us the ability to fix a bug on Tuesday and have it on users' machines on Wednesday — which is the actual reason to ship outside the store, and the thing that makes the trade worthwhile.