Auto-update server-only Macs and replace Sparkle's update UI

This commit is contained in:
Ian Gulliver
2026-10-19 16:04:47 -07:00
parent 26404758c6
commit 8bca7a4369
8 changed files with 226 additions and 11 deletions
+97 -5
View File
@@ -1,16 +1,108 @@
import Foundation
import AppKit
import Sparkle
@MainActor
final class Updater {
final class Updater: NSObject, SPUUserDriver, SPUUpdaterDelegate {
static let shared = Updater()
private lazy var controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
private var updater: SPUUpdater?
private var now = false
private var staged: (item: SUAppcastItem, install: () -> Void)?
func start() {
_ = controller
let updater = SPUUpdater(hostBundle: .main, applicationBundle: .main, userDriver: self, delegate: self)
self.updater = updater
do {
try updater.start()
} catch {
tell("Updates are off", error.localizedDescription)
}
}
func check() {
controller.checkForUpdates(nil)
if let staged {
if ask(staged.item) {
staged.install()
}
return
}
updater?.checkForUpdates()
}
private func ask(_ item: SUAppcastItem) -> Bool {
let current = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
let alert = NSAlert()
alert.messageText = "Byre \(item.displayVersionString) is available"
alert.informativeText = "You have \(current)."
alert.addButton(withTitle: "Update Now")
alert.addButton(withTitle: "Update on Quit")
NSApp.activate()
return alert.runModal() == .alertFirstButtonReturn
}
private func tell(_ message: String, _ info: String) {
let alert = NSAlert()
alert.messageText = message
alert.informativeText = info
NSApp.activate()
alert.runModal()
}
func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool {
staged = (item, immediateInstallHandler)
if ask(item) {
immediateInstallHandler()
}
return true
}
func show(_ request: SPUUpdatePermissionRequest, reply: @escaping (SUUpdatePermissionResponse) -> Void) {
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {}
func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping (SPUUserUpdateChoice) -> Void) {
now = ask(appcastItem)
if !now && state.stage == .installing {
reply(.dismiss)
return
}
reply(.install)
}
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {}
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) {}
func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) {
tell(error.localizedDescription, (error as NSError).localizedRecoverySuggestion ?? "")
acknowledgement()
}
func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) {
tell("Update failed", error.localizedDescription)
acknowledgement()
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {}
func showDownloadDidReceiveData(ofLength length: UInt64) {}
func showDownloadDidStartExtractingUpdate() {}
func showExtractionReceivedProgress(_ progress: Double) {}
func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
reply(now ? .install : .dismiss)
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
acknowledgement()
}
func dismissUpdateInstallation() {}
}
+78
View File
@@ -0,0 +1,78 @@
import AppKit
import Sparkle
func log(_ message: String) {
FileHandle.standardError.write(Data((message + "\n").utf8))
}
@MainActor
final class Driver: NSObject, SPUUserDriver {
func show(_ request: SPUUpdatePermissionRequest, reply: @escaping (SUUpdatePermissionResponse) -> Void) {
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {}
func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping (SPUUserUpdateChoice) -> Void) {
log("installing \(appcastItem.displayVersionString) (\(appcastItem.versionString))")
reply(.install)
}
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {}
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) {}
func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) {
log("no update: \(error.localizedDescription)")
exit(0)
}
func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) {
log("update failed: \(error.localizedDescription)")
exit(1)
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {}
func showDownloadDidReceiveData(ofLength length: UInt64) {}
func showDownloadDidStartExtractingUpdate() {}
func showExtractionReceivedProgress(_ progress: Double) {}
func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
reply(.install)
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
log("installing")
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
log("installed")
exit(0)
}
func dismissUpdateInstallation() {
exit(0)
}
}
let app = Bundle.main.executableURL!.resolvingSymlinksInPath().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
if NSWorkspace.shared.runningApplications.contains(where: { $0.bundleURL?.standardizedFileURL == app.standardizedFileURL }) {
log("\(app.path) is running; its own updater handles updates")
exit(0)
}
let bundle = Bundle(url: app)!
let driver = Driver()
let updater = SPUUpdater(hostBundle: bundle, applicationBundle: bundle, userDriver: driver, delegate: nil)
do {
try updater.start()
} catch {
log("start updater: \(error.localizedDescription)")
exit(1)
}
updater.checkForUpdates()
RunLoop.main.run()