Fork Notice: This is a fork of s1ntoneli/AppUpdater, which itself is a rewrite of mxcl/AppUpdater.
This fork removes UI components and changelog localization to provide a headless update library. Bring your own UI.
A simple app-updater for macOS, checks your GitHub releases for a binary asset, downloads it, and provides a validated bundle ready for installation.
- Assets must be named:
\(name)-\(semanticVersion).ext. See Semantic Version - Only non-sandboxed apps are supported
- Full semantic versioning support: we understand alpha/beta etc.
- We check that the code-sign identity of the download matches the running app before updating.
- We support zip files or tarballs.
package.dependencies.append(.package(url: "https://github.com/jorisnoo/AppUpdater.git", from: "3.0.0"))let updater = AppUpdater(owner: "yourname", repo: "YourApp")Full initializer:
let updater = AppUpdater(
owner: "yourname",
repo: "YourApp",
releasePrefix: "YourApp", // defaults to repo name
interval: 24 * 60 * 60, // background check interval in seconds
provider: GithubReleaseProvider()
)updater.check()This checks GitHub for new releases, downloads the asset if a newer version is found, validates the code signature, and transitions to the .downloaded state. It does not install the update automatically.
// Get the bundle from the downloaded state
if case .downloaded(_, _, let bundle) = updater.state {
updater.install(bundle)
}The install(_:) method replaces the running app with the downloaded bundle and relaunches.
For a better user experience, you can defer updates and install them when the user quits:
- On download completion, store the update for later:
if case .downloaded(let release, let asset, let bundle) = updater.state {
// Persist bundle to stable location (temp directory may be cleaned)
let persistedURL = try DeferredUpdate.persistBundle(bundle)
// Create and store the deferred update info
let deferred = DeferredUpdate(
bundlePath: persistedURL.path,
releaseVersion: release.tagName.description,
releaseName: release.name,
assetName: asset.name
)
// Store in UserDefaults (or your preferred storage)
UserDefaults.standard.set(try? JSONEncoder().encode(deferred), forKey: "deferredUpdate")
}- In
applicationShouldTerminate, install without relaunch:
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard let data = UserDefaults.standard.data(forKey: "deferredUpdate"),
let deferred = try? JSONDecoder().decode(DeferredUpdate.self, from: data),
let bundle = deferred.loadBundle() else {
return .terminateNow
}
Task { @MainActor in
do {
try updater.replaceBundle(bundle)
DeferredUpdate.cleanup()
UserDefaults.standard.removeObject(forKey: "deferredUpdate")
} catch {
// Log error but still quit
}
sender.reply(toApplicationShouldTerminate: true)
}
return .terminateLater
}The app quits normally; the next launch uses the new version.
- On app launch, validate the deferred update still exists (the bundle may have been deleted):
func applicationDidFinishLaunching(_ notification: Notification) {
// Validate deferred update bundle still exists
if let data = UserDefaults.standard.data(forKey: "deferredUpdate"),
let deferred = try? JSONDecoder().decode(DeferredUpdate.self, from: data),
!deferred.isValid {
// Bundle was deleted, clear the stale preference
UserDefaults.standard.removeObject(forKey: "deferredUpdate")
DeferredUpdate.cleanup()
}
}AppUpdater uses a state machine to track progress:
.none → .newVersionDetected → .downloading → .downloaded
| State | Description |
|---|---|
.none |
No update available or not yet checked |
.newVersionDetected(release, asset) |
A newer version was found, download starting |
.downloading(release, asset, fraction) |
Download in progress (0.0 to 1.0) |
.downloaded(release, asset, bundle) |
Ready to install; bundle is validated |
import SwiftUI
import AppUpdater
struct UpdateView: View {
@ObservedObject var updater: AppUpdater
var body: some View {
switch updater.state {
case .none:
Text("No updates available")
case .newVersionDetected(let release, _):
Text("Found \(release.tagName.description)")
case .downloading(_, _, let fraction):
ProgressView(value: fraction)
case .downloaded(let release, _, let bundle):
VStack {
Text("Ready to install \(release.tagName.description)")
Button("Install & Restart") {
updater.install(bundle)
}
}
}
}
}AppUpdater is an ObservableObject, observe the state property to build your own update UI.
- Core:
AppUpdaterchecks GitHub releases, selects a viable asset, downloads, validates code-signing, and installs. - Providers: Data source abstraction.
GithubReleaseProvider(default) talks to GitHub API and assets.MockReleaseProvider(testing) serves releases from bundled JSON and produces minimal .app archives for offline testing.
Swap providers via initializer or at runtime:
let updater = AppUpdater(owner: "...", repo: "...", provider: GithubReleaseProvider())
// or
updater.provider = MockReleaseProvider()
updater.skipCodeSignValidation = true // recommended when using mocksMock data lives in Sources/AppUpdater/Resources/Mocks/releases.mock.json.
Run the mock provider from the command line:
swift run AppUpdaterMockRunnerShows state transitions and completes without touching your installed app.
swift testThe test suite covers version comparison, asset selection, download simulation, and provider behavior.
Use Console.app and filter by "AppUpdater" subsystem to see debug logs.
- mxcl/AppUpdater - Original implementation
- s1ntoneli/AppUpdater - Upstream fork with async/await rewrite