Unix/macOS Auto-Updates
- Goal
-
Users on macOS and Linux reach newer builds without manual zip hunts, without corrupting a running install.
- Why
- Windows sibling
Pick a primary path
| OS | Path | When to use |
|---|---|---|
macOS |
Mac App Store |
You can meet store policies; want OS-managed updates |
macOS |
Sparkle (or similar) + notarized app |
Direct distribution outside the Store with guided UX |
macOS |
Homebrew cask |
Developer/power-user audience already on brew |
Linux |
Distro packages (apt, dnf, …) / Flathub / Snap |
Default for "feels native"; updates ride OS tools |
Linux |
AppImage + in-app or companion updater |
Portable single-file apps; you control the channel |
Either |
Manual download page |
Last resort; never the only 1.0 path for a GUI app |
Combining channels is fine (Homebrew and Sparkle builds) if About shows which channel the binary belongs to and you do not cross-upgrade formats blindly. Package-managed installs (App Store, Homebrew, Flatpak, apt, …) must not self-replace—deep-link the native upgrade instead.
Signed update feed (direct / Sparkle / AppImage)
-
Host a stable feed URL on the marketing site (e.g.
/updates/latest.jsonor a Sparkle appcast). -
CI also attaches a versioned feed copy to each GitHub Release.
-
The app fetches the stable URL only.
-
Stamp channel + install source in the binary so beta never "upgrades" onto the wrong track.
Why "overwrite the running binary" fails
On Unix, a running executable is mapped by the loader.
-
Linux: writing to the executable path of a running process typically fails with
ETXTBSY(text file busy), or you replace the directory entry while the old inode keeps running—easy to confuse users and updaters. -
macOS: replacing a running
.appbundle while frameworks are mapped is fragile; Sparkle-class tools terminate the app, replace the bundle, then relaunch.
Rule: stage the new bits beside or under a versioned directory, then quit → replace → start, or let the package manager stop services/apps as part of the transaction.
One-shot apply helper (not a long-lived launcher)
For download-page / Sparkle / AppImage flows, prefer a tiny one-shot process that runs only at apply time:
-
App downloads and verifies
-
App writes "update pending"
-
App quits completely (process tree)
-
One-shot waits for PID exit
-
One-shot replaces the bundle / AppImage / runs the package transaction
-
One-shot relaunches the app
Do not keep a permanent separate launcher just to own updates. Check and download may live in the main app; replace starts only after exit.
Safe apply patterns
1. Package manager does the work (preferred on Linux)
-
Publish to your PPA/COPR/Flathub/etc. from CI.
-
Tell the user (or a helper) to run the native upgrade, e.g.
flatpak update com.example.Apporsudo apt install --only-upgrade your-app. -
If the app must not be running: show "Install update and restart" which:
-
Saves state
-
Exits with a dedicated code or touches a flag file
-
Launches a tiny updater stub or
systemdoneshot / shell script that waits for the PID to exit, runs the package upgrade, then relaunches
-
Do not kill -9 as the first resort—send polite quit (SIGTERM / app quit protocol), wait, then escalate only if hung.
2. Scheduled package updates (fleet / always-on machines)
For daemons or kiosk hosts:
-
Use a systemd timer or cron that runs a constrained upgrade command for your package units only (avoid blind
apt full-upgradefrom an app). -
Gate on battery/metered network when relevant.
-
Log results to the app’s log directory; surface last success/failure in About.
-
Prefer
Unattended-Upgradesorigin pinning or Flatpak remote timers over custom root scripts when possible.
Example shape (illustrative—adapt to your package name and security model):
# /etc/systemd/system/your-app-update.timer
[Timer]
OnCalendar=hourly
Persistent=true
# your-app-update.service runs a script that:
# 1) checks a feed or `apt-get -s upgrade`
# 2) if update exists and policy allows: notify user session OR
# 3) for headless: stop your-app.service, upgrade, start
Desktop user sessions should prompt before stopping GUI apps mid-work unless the update is mandatory for security.
3. Sparkle-class (macOS direct)
-
CI produces a notarized
.app(zip/dmg) + appcast/feed entry with EdDSA/signature. -
App checks feed on an interval + "Check for Updates…"
-
On approve: download → verify → quit → replace
.app→ relaunch → show What’s new.
Honor "Automatically download updates" vs "Notify only," and a managed-disable defaults key for enterprises.
4. AppImage / portable Linux binary
-
Download to
App.AppImage.new(or versioned name) on the same filesystem. -
chmod +xand verify signature/hash. -
Atomic rename over the old path only after the running process has exited—or rename old to
.old, rename new into place, then relaunch and delete.oldon next start. -
Relaunch via a stub:
# updater-stub.sh (sketch)
APP="$1"
NEW="$2"
PID="$3"
while kill -0 "$PID" 2>/dev/null; do sleep 0.2; done
mv -f "$NEW" "$APP"
exec "$APP"
The main app starts this stub, then exits cleanly.
5. Homebrew / apt / Store user education
If the install came from Homebrew, apt, Flatpak, or the Mac App Store:
-
Do not run the Sparkle/AppImage self-replace path
-
About should say e.g. "Installed via Homebrew — run `brew upgrade your-cask`"
-
Check for updates deep-links docs or runs
brew outdated/ opens the Store when detectable
Killing processes responsibly
| Step | Practice |
|---|---|
1. Ask |
GUI: modal "Update ready — Restart now / Later" |
2. Save |
Flush documents/settings; refuse update if unsaved and user cancels |
3. Quit |
App quit API / SIGTERM to your process group — before any replace |
4. Wait |
Poll PID until exit; timeout → offer Force quit |
5. Replace |
Package transaction, bundle replace, or atomic AppImage rename |
6. Relaunch |
Same argv/env channel; pass a |
Background helpers (tray, agents): include them in the quit graph so file locks clear.
Security checklist
-
Feed and artifacts over TLS; stable marketing URL + versioned release asset
-
Signatures/hashes verified before replace (Sparkle EdDSA, GPG for apt, Flatpak remote trust, minisign/cosign for AppImage—pick one story and stick to it)
-
No silent elevation loops; prefer per-user installs when possible
-
Enterprise disable / pin documented; About shows when policy disabled updates
-
Channel stamped in the binary so package-managed installs never self-replace onto the wrong track