Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 55 additions & 44 deletions AppUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ public final class AppUpdater {
private var active: Task<Update?, Swift.Error>?
private let owner: String
private let repo: String
private let configuration: Configuration
private let session: URLSession
private let hasExecutable: @Sendable () -> Bool
private let currentVersion: @Sendable () throws -> Version
private let fetchReleases: @Sendable () async throws -> [Release]
private let stageAsset: @MainActor @Sendable (Release.Asset) async throws -> Update
private let prepareAsset: @MainActor @Sendable (Release.Asset) async throws -> PreparedUpdate

public var allowPrereleases = false

Expand Down Expand Up @@ -49,6 +50,7 @@ public final class AppUpdater {
let session = URLSession(configuration: sessionConfiguration)
self.owner = owner
self.repo = repo
self.configuration = configuration
self.session = session
hasExecutable = { Bundle.main.executableURL != nil }
currentVersion = { try Bundle.main.appVersion }
Expand All @@ -60,8 +62,8 @@ public final class AppUpdater {
configuration: configuration
)
}
stageAsset = { asset in
try await Self.stageUpdate(
prepareAsset = { asset in
try await Self.prepareUpdate(
with: asset,
replacing: .main,
session: session,
Expand All @@ -73,18 +75,20 @@ public final class AppUpdater {
init(
owner: String,
repo: String,
configuration: Configuration = .init(),
hasExecutable: @escaping @Sendable () -> Bool = { true },
currentVersion: @escaping @Sendable () throws -> Version,
fetchReleases: @escaping @Sendable () async throws -> [Release],
stageAsset: @escaping @MainActor @Sendable (Release.Asset) async throws -> Update
prepareAsset: @escaping @MainActor @Sendable (Release.Asset) async throws -> PreparedUpdate
) {
self.owner = owner
self.repo = repo
self.configuration = configuration
self.session = .shared
self.hasExecutable = hasExecutable
self.currentVersion = currentVersion
self.fetchReleases = fetchReleases
self.stageAsset = stageAsset
self.prepareAsset = prepareAsset
}

public func check() async throws -> Update? {
Expand All @@ -93,11 +97,12 @@ public final class AppUpdater {
}

let repo = repo
let configuration = configuration
let allowPrereleases = allowPrereleases
let hasExecutable = hasExecutable
let currentVersion = currentVersion
let fetchReleases = fetchReleases
let stageAsset = stageAsset
let prepareAsset = prepareAsset

let task = Task<Update?, Swift.Error> {
guard hasExecutable() else {
Expand All @@ -106,15 +111,20 @@ public final class AppUpdater {

let appVersion = try currentVersion()
let releases = try await fetchReleases()
guard let asset = try releases.findViableUpdate(
guard let update = try releases.findViableUpdate(
appVersion: appVersion,
repo: repo,
prerelease: allowPrereleases
) else {
return nil
}
try Self.validateAssetMetadata(update.asset, configuration: configuration)

return try await stageAsset(asset)
return Update(
version: update.version.description,
assetName: update.asset.name,
prepare: { try await prepareAsset(update.asset) }
)
Comment thread
mxcl marked this conversation as resolved.
}

active = task
Expand Down Expand Up @@ -144,25 +154,13 @@ public final class AppUpdater {
return try decoder.decode([Release].self, from: data)
}

private static func stageUpdate(
private static func prepareUpdate(
with asset: Release.Asset,
replacing installedAppBundle: Bundle,
session: URLSession,
configuration: Configuration
) async throws -> Update {
guard asset.browserDownloadURL.scheme == "https" else {
throw AppUpdaterError.insecureDownloadURL
}

guard let contentType = asset.contentType, contentType == .dmg else {
throw AppUpdaterError.unsupportedAsset(asset.name)
}
guard asset.size > 0 else {
throw AppUpdaterError.invalidGitHubResponse
}
guard asset.size <= configuration.maximumDownloadBytes else {
throw AppUpdaterError.resourceLimitExceeded("download size")
}
) async throws -> PreparedUpdate {
try validateAssetMetadata(asset, configuration: configuration)

let tmpdir = try Self.stagingDirectory()
do {
Expand Down Expand Up @@ -200,24 +198,37 @@ public final class AppUpdater {
}

let lease = StagingLease(root: tmpdir, mount: mount)
return Update(
return try await Installation.prepare(
assetName: asset.name,
prepare: {
try await Installation.prepare(
assetName: asset.name,
lease: lease,
installedBundle: installedAppBundle,
limits: limits
)
},
discard: { await lease.discard() }
lease: lease,
installedBundle: installedAppBundle,
limits: limits
)
} catch {
try? FileManager.default.removeItem(at: tmpdir)
throw error
}
}

private static func validateAssetMetadata(
_ asset: Release.Asset,
configuration: Configuration
) throws {
guard asset.browserDownloadURL.scheme == "https" else {
throw AppUpdaterError.insecureDownloadURL
}

guard let contentType = asset.contentType, contentType == .dmg else {
throw AppUpdaterError.unsupportedAsset(asset.name)
}
guard asset.size > 0 else {
throw AppUpdaterError.invalidGitHubResponse
}
guard asset.size <= configuration.maximumDownloadBytes else {
throw AppUpdaterError.resourceLimitExceeded("download size")
}
}

static func stagingDirectory() throws -> URL {
let baseURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let url = baseURL.appendingPathComponent(
Expand All @@ -236,36 +247,31 @@ public final class AppUpdater {
@MainActor
public final class Update {
typealias PrepareOperation = @MainActor () async throws -> PreparedUpdate
typealias DiscardOperation = @MainActor () async -> Void

public let version: String
public let assetName: String
private var prepareOperation: PrepareOperation?
private var discardOperation: DiscardOperation?

public func prepareInstallation() async throws -> PreparedUpdate {
guard let operation = prepareOperation else {
throw AppUpdaterError.invalidUpdateState
}
prepareOperation = nil
discardOperation = nil
return try await operation()
}

public func discard() async {
let operation = discardOperation
prepareOperation = nil
discardOperation = nil
await operation?()
}

init(
version: String,
assetName: String,
prepare: @escaping PrepareOperation,
discard: @escaping DiscardOperation
prepare: @escaping PrepareOperation
) {
self.version = version
self.assetName = assetName
prepareOperation = prepare
discardOperation = discard
}
}

Expand Down Expand Up @@ -587,19 +593,24 @@ enum ContentType: Decodable, Equatable {
}
}

struct AvailableUpdate {
let version: Version
let asset: Release.Asset
}

extension Array where Element == Release {
func findViableUpdate(
appVersion: Version,
repo: String,
prerelease: Bool
) throws -> Release.Asset? {
) throws -> AvailableUpdate? {
let suitableReleases = prerelease ? self : filter { !$0.prerelease }
for release in suitableReleases.sorted().reversed() {
guard appVersion < release.tagName else {
return nil
}
if let asset = release.viableAsset(forRepo: repo) {
return asset
return AvailableUpdate(version: release.tagName, asset: asset)
}
}
return nil
Expand Down
30 changes: 19 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ GitHub Releases, validates a DMG, replaces the running app, and relaunches it.
[coveralls-badge]: https://coveralls.io/repos/github/mxcl/AppUpdater/badge.svg
[coveralls]: https://coveralls.io/github/mxcl/AppUpdater

AppUpdater supports macOS 12 and later. Version 3 has a source-breaking API.
AppUpdater supports macOS 12 and later. Version 4 separates update discovery
from downloading and preparing an installation.

## Package

```swift
package.dependencies.append(
.package(url: "https://github.com/mxcl/AppUpdater.git", from: "3.0.0")
.package(url: "https://github.com/mxcl/AppUpdater.git", from: "4.0.0")
)
```

Expand Down Expand Up @@ -51,9 +52,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { @MainActor in
do {
guard let update = try await updater.check() else { return }
print("Version \(update.version) is available")

// The app can keep operating while this runs. Finder may ask
// for authorization when the app lives in a protected folder.
// This downloads and validates the update. The app can keep
// operating while it runs. Finder may ask for authorization
// when the app lives in a protected folder.
let prepared = try await update.prepareInstallation()

// Save documents, stop background work, close helper processes,
Expand All @@ -70,14 +73,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
```

`check()` downloads the DMG, mounts it read-only and non-browsable, enforces the
configured resource limits, and validates the app. It returns a one-shot
`Update` without exposing staging paths.
`check()` fetches bounded GitHub release metadata and returns a lightweight,
one-shot `Update` with `version` and `assetName`. It does not download the DMG.

`prepareInstallation()` copies the DMG beside the installed app, mounts that
copy read-only, and repeats the resource and signature checks. The returned
`PreparedUpdate` is also one-shot. Call `discard()` on either object if you
decide not to continue.
`prepareInstallation()` downloads the DMG, mounts it read-only and
non-browsable, enforces the configured resource limits, and validates the app.
It then copies the DMG beside the installed app, mounts that copy read-only,
and repeats the resource and signature checks. The returned `PreparedUpdate`
is also one-shot. Call `discard()` on either object if you decide not to
continue.

Call `installAndRelaunch()` only after the host has saved its state, stopped
background work, and ceased loading bundle code or resources. The running
Expand Down Expand Up @@ -120,6 +124,10 @@ AppUpdater aims to prevent privilege amplification. A same-user attacker must
not be able to replace a downloaded candidate and then borrow Finder's
authorization to modify an app that the user cannot otherwise replace.

The result of `check()` is advisory GitHub metadata, not an authenticated app.
Only `prepareInstallation()` downloads and authenticates the candidate. Do not
grant privileges or stop security services based only on an available update.

For every candidate, AppUpdater requires a valid Developer ID Application
signature. The installed and candidate apps must have the same Team ID, signing
identifier, and bundle identifier. Validation covers all architectures, nested
Expand Down
Loading
Loading