How my Mac apps update themselves from GitHub Releases

By

Soundscape and CLI Tools check GitHub once a day, download the new release, verify it, and replace themselves. Here's how it works, in one Swift file with no dependencies.

~~~

My Mac apps Soundscape and CLI Tools update themselves. Once a day they ask GitHub whether there’s a new release. When there is, they show what’s new and offer to install it:

Soundscape's update dialog, offering version 1.0.2 with its release notes and three buttons: Install and Relaunch, Later, Skip This Version

Click Install and Relaunch, and the app downloads the new version, checks it, puts it in place of the old one, and opens again.

All of this lives in one Swift file with no dependencies, AppUpdater.swift. It’s 436 lines, and every app gets an identical copy.

Why not Sparkle

Sparkle is the usual way to update a Mac app that’s not on the App Store. It’s a framework you add to your app. You host an appcast, an XML feed that lists your versions, and you sign every update with a private key.

My apps are open source, and every release is already a GitHub release with the app zipped and attached. So the release can be the feed. The app reads it from the GitHub API, and there’s nothing else to host or sign.

The release is the feed

Every release follows three rules:

The GitHub API returns the latest release of a repo at https://api.github.com/repos/flaviocopes/soundscape/releases/latest. This is the part of the answer the updater reads:

{
  "tag_name": "v1.0.2",
  "html_url": "https://github.com/flaviocopes/soundscape/releases/tag/v1.0.2",
  "body": "Soundscape mixes the Background Sounds built into macOS...",
  "assets": [
    {
      "name": "Soundscape-1.0.2.zip",
      "browser_download_url": "https://github.com/flaviocopes/soundscape/releases/download/v1.0.2/Soundscape-1.0.2.zip",
      "digest": "sha256:8b0836d9903ea43fd595c7f281b310a567940ba3275e6bc5cdc1fdb409469d50"
    }
  ]
}

Notice the digest. GitHub computes the SHA-256 of every file you upload to a release, so we get a checksum for free. The updater uses it to check the download.

Checking for a new version

The app decodes the JSON into a small struct. CodingKeys maps GitHub’s names to shorter ones:

struct Release: Decodable {
  let tag: String
  let body: String?
  let pageURL: URL
  let assets: [Asset]

  var version: String { tag.hasPrefix("v") ? String(tag.dropFirst()) : tag }

  enum CodingKeys: String, CodingKey {
    case tag = "tag_name", body, pageURL = "html_url", assets
  }

  struct Asset: Decodable {
    let name: String
    let url: URL
    let digest: String?

    enum CodingKeys: String, CodingKey {
      case name, url = "browser_download_url", digest
    }
  }
}

GitHub asks every API client to send a User-Agent, so the request sends the app’s name and version:

let url = URL(string: "https://api.github.com/repos/flaviocopes/soundscape/releases/latest")!
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("Soundscape/1.0.1", forHTTPHeaderField: "User-Agent")

let (data, response) = try await URLSession.shared.data(for: request)
if (response as? HTTPURLResponse)?.statusCode == 200 {
  let release = try JSONDecoder().decode(Release.self, from: data)
}

A 404 means the repo has no releases yet, so there’s nothing to do.

The installed version comes from Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString"). Comparing it with the release version as strings doesn’t work, because "1.0.10" sorts before "1.0.9". So we compare the numbers one by one:

func isVersion(_ version: String, newerThan current: String) -> Bool {
  let new = version.split(separator: ".").map { Int($0) ?? 0 }
  let old = current.split(separator: ".").map { Int($0) ?? 0 }
  for index in 0..<max(new.count, old.count) {
    let a = index < new.count ? new[index] : 0
    let b = index < old.count ? old[index] : 0
    if a != b { return a > b }
  }
  return false
}

isVersion("1.0.10", newerThan: "1.0.9") //true
isVersion("1.0", newerThan: "1.0.0") //false

The app checks five seconds after launch, then every hour while it runs. Each time, it goes ahead only if 24 hours have passed since the last check, which it saves in UserDefaults:

func checkIfDue() {
  let lastCheck = UserDefaults.standard.object(forKey: "AppUpdaterLastCheck") as? Date ?? .distantPast
  guard Date().timeIntervalSince(lastCheck) >= 24 * 60 * 60 else { return }
  Task { await check() }
}

So an app that stays open for weeks still checks every day, and opening it ten times in a day makes one request. The GitHub API allows 60 requests per hour without a token, per IP address, so one a day is nowhere near the limit.

Showing what’s new

The dialog is an NSAlert. The release notes go in its accessory view, a small SwiftUI view that renders the Markdown with AttributedString.

My release notes end with an ## Install section, with the Gatekeeper steps and the checksum for people who download the zip by hand. Someone updating from inside the app doesn’t need it, so the updater cuts the notes at that heading. It also turns the other headings into bold text and the - bullets into •, because the view only renders inline Markdown.

Skip This Version saves the version in UserDefaults, and the daily check doesn’t ask about it again. The Check for Updates… menu item ignores it, so you can still install a version you skipped.

Downloading next to the app

The zip downloads into a temporary folder on the same volume as the app. FileManager has a special folder for this, meant for files that will replace another one:

let folder = try FileManager.default.url(
  for: .itemReplacementDirectory, in: .userDomainMask,
  appropriateFor: Bundle.main.bundleURL, create: true)

let (downloaded, _) = try await URLSession.shared.download(from: asset.url)
let zip = folder.appending(path: asset.name)
try FileManager.default.moveItem(at: downloaded, to: zip)

Being on the same volume makes the final swap a rename, which is instant, instead of a copy. While the zip downloads, a small window shows a progress bar and a Cancel button. The progress comes from the download task’s progress property.

Checking the download

Before the app replaces itself, the updater checks four things. If any check fails, it deletes the download, shows the error, and leaves the installed app alone.

First, the checksum. CryptoKit computes the SHA-256, reading the file 1 MB at a time so the whole zip is never in memory:

import CryptoKit

func sha256(of file: URL) throws -> String {
  let handle = try FileHandle(forReadingFrom: file)
  defer { try? handle.close() }
  var hasher = SHA256()
  while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
    hasher.update(data: chunk)
  }
  return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}

The result must match GitHub’s digest:

guard asset.digest == "sha256:\(try sha256(of: zip))" else { throw UpdateError.checksum }

Then ditto, the same tool that made the zip, unpacks it:

let ditto = Process()
ditto.executableURL = URL(filePath: "/usr/bin/ditto")
ditto.arguments = ["-x", "-k", zip.path, folder.path]
try ditto.run()
ditto.waitUntilExit()

Next, the updater reads the new app’s Info.plist. The bundle identifier must be the same as the running app’s, and the version must be the one in the tag. This catches a release with the wrong zip attached. It also catches a version I forgot to bump, which would otherwise make the app offer the same update every day, forever.

Last, the code signature. The Security framework runs the same checks as codesign --verify --deep --strict:

var code: SecStaticCode?
let flags = SecCSFlags(rawValue: UInt32(kSecCSCheckAllArchitectures | kSecCSCheckNestedCode | kSecCSStrictValidate))
guard SecStaticCodeCreateWithPath(app as CFURL, [], &code) == errSecSuccess, let code,
  SecStaticCodeCheckValidity(code, flags, nil) == errSecSuccess
else { throw UpdateError.signature }

My apps are ad-hoc signed, so the signature doesn’t say who built the app. It proves that no file in the bundle changed after it was signed.

Swapping the app

FileManager.replaceItemAt puts the new app where the old one was:

try FileManager.default.replaceItemAt(
  Bundle.main.bundleURL, withItemAt: newApp,
  backupItemName: nil, options: .usingNewMetadataOnly)

The .usingNewMetadataOnly option matters because of quarantine. When you download an app with Safari, macOS gives it a com.apple.quarantine attribute, and that’s why the first launch shows the Gatekeeper warning. With this option, the new app keeps only its own metadata and doesn’t inherit anything from the old one.

The new app has no quarantine attribute of its own either. A browser adds it because it asks macOS to, and URLSession in an app that’s not sandboxed doesn’t. So the updated app opens without any warning.

Relaunching

A running app can’t open itself again, because open would bring the running copy to the front. So the updater starts a tiny shell script that waits for the app to quit, then opens it:

let shell = Process()
shell.executableURL = URL(filePath: "/bin/sh")
shell.arguments = [
  "-c", "while kill -0 \"$0\" 2>/dev/null; do sleep 0.2; done; open \"$1\"",
  String(ProcessInfo.processInfo.processIdentifier), Bundle.main.bundlePath,
]
try shell.run()
NSApp.terminate(nil)

kill -0 checks that the process still exists, without sending it a signal. The process ID and the app’s path arrive as $0 and $1, so a name with a space, like CLI Tools.app, needs no extra quoting.

When it can’t install

In three cases the first button becomes Open Release Page, and you download the update by hand:

The limits

The updater trusts my GitHub account. The checksum comes from the same release as the file, so it catches a broken or incomplete download, not a malicious release. Sparkle’s signing key protects against that, if you need it.

Ad-hoc signed apps get a new signature with every build. macOS ties permissions like Accessibility or Screen Recording to the signature, so an app that needs them has to ask again after each update. My two apps don’t need any. If yours does, you want a Developer ID, and the free Ship macOS Apps course walks through signing and notarizing.

It only works with public repos, because the app calls the API without a token. And it’s for apps that aren’t sandboxed, because a sandboxed app can’t replace itself in /Applications.

Use it in your app

The file is MIT licensed, like the rest of Soundscape. Copy it into your app, start it with your repo, and add the menu item after About:

@main
struct CliToolsApp: App {
  init() {
    AppUpdater.shared.start(repository: "flaviocopes/cli-tools")
  }

  var body: some Scene {
    WindowGroup {
      ContentView()
    }
    .commands {
      CommandGroup(after: .appInfo) {
        Button("Check for Updates…") {
          AppUpdater.shared.checkForUpdates()
        }
      }
    }
  }
}

Then publish every release with the three rules above. Tell your users about the daily check in the README, along with the command that turns it off:

defaults write com.flaviocopes.clitools AppUpdaterAutomaticChecks -bool false

The first release with the updater still needs one download by hand. From the next one, the app updates itself.

Tagged: Swift · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about swift: