update progress

This commit is contained in:
Ian Gulliver
2026-10-19 19:36:42 -07:00
parent 88d1146778
commit 52a231e635
2 changed files with 125 additions and 10 deletions
+1 -1
View File
@@ -157,7 +157,7 @@ On first launch, and from Add Server…, the window shows one field, Server URL
## Updates
`Updater.swift` runs a Sparkle `SPUUpdater`, started at launch, with `Updater` as its user driver and delegate instead of Sparkle's standard windows, so no update UI offers Skip or turning automatic updates off, which `byre-update` would override. It reads `SUFeedURL` from `mac/Info.plist`, the appcast of the newest release (see "Releases" in `docs/operations.md`), checks it daily (`SUEnableAutomaticChecks`, so Sparkle doesn't first ask whether to), compares the appcast's `sparkle:version` with `CFBundleVersion`, and verifies the download's EdDSA signature against `SUPublicEDKey` and its Developer ID signature against the running app's. `SUAutomaticallyUpdate` makes a scheduled check download a new release in the background and stage it to install when the app quits; Sparkle then calls the delegate's `willInstallUpdateOnQuit`, which shows an alert, "Byre <version> is available", with **Update Now** (Sparkle installs and relaunches the app) and **Update on Quit** (it stays staged), and returns true, so Sparkle shows nothing of its own later. Returning true also holds Sparkle's update session open until the app quits, so no further checks run; `Updater` keeps the staged item and Sparkle's install handler, and Check for Updates… then shows the alert for that update again instead of checking. Otherwise Check for Updates… in the app menu checks at once: an update found shows the same alert (`showUpdateFound`), and either answer downloads it; at `showReadyToInstallAndRelaunch` the driver answers Install for Update Now and Dismiss for Update on Quit, which leaves it to install on quit. No update, or an error, shows an alert with Sparkle's message. The alerts are `NSAlert.runModal`; there is no download progress UI, and the appcast carries no release notes. When the bundle is replaced, a server running from its `Contents/Helpers/byre` restarts itself onto the new binary (see "Running as a service" in `docs/operations.md`).
`Updater.swift` runs a Sparkle `SPUUpdater`, started at launch, with `Updater` as its user driver and delegate instead of Sparkle's standard windows, so no update UI offers Skip or turning automatic updates off, which `byre-update` would override. It reads `SUFeedURL` from `mac/Info.plist`, the appcast of the newest release (see "Releases" in `docs/operations.md`), checks it daily (`SUEnableAutomaticChecks`, so Sparkle doesn't first ask whether to), compares the appcast's `sparkle:version` with `CFBundleVersion`, and verifies the download's EdDSA signature against `SUPublicEDKey` and its Developer ID signature against the running app's. `SUAutomaticallyUpdate` makes a scheduled check download a new release in the background and stage it to install when the app quits; Sparkle then calls the delegate's `willInstallUpdateOnQuit`, which shows an alert, "Byre <version> is available", with **Update Now** (Sparkle installs and relaunches the app) and **Update on Quit** (it stays staged), and returns true, so Sparkle shows nothing of its own later. Returning true also holds Sparkle's update session open until the app quits, so no further checks run; `Updater` keeps the staged item and Sparkle's install handler, and Check for Updates… then shows the alert for that update again instead of checking. Otherwise Check for Updates… in the app menu checks at once: an update found shows the same alert (`showUpdateFound`), and either answer downloads it; at `showReadyToInstallAndRelaunch` the driver answers Install for Update Now and Dismiss for Update on Quit, which leaves it to install on quit. No update, or an error, shows an alert with Sparkle's message. The alerts are `NSAlert.runModal`, and the appcast carries no release notes. After Update Now, an `NSPanel` hosting `UpdateProgressView` shows each phase on its own bar starting from zero, with no combined percentage: Downloading (bytes received of the `Content-Length`, with Cancel, which calls Sparkle's cancellation), Extracting (Sparkle's extraction progress) and Installing (indeterminate, until the app relaunches). An Update on Quit download shows no panel. Sparkle's `dismissUpdateInstallation`, an error, or the relaunch closes it. When the bundle is replaced, a server running from its `Contents/Helpers/byre` restarts itself onto the new binary (see "Running as a service" in `docs/operations.md`).
A Mac that runs the server but not the app is updated by `byre-update` (`mac/Sources/byre-update/main.swift`, in `Contents/Helpers`), which the server runs daily. It drives the same Sparkle updater with no UI: an `SPUUpdater` whose host and application bundle are the app it sits in, with a user driver that answers Install to every offer and logs to stderr instead of showing windows. It makes one check (`checkForUpdates`, which, as a user-initiated check, would ignore a version skipped in the app), and exits when Sparkle reports no update, an error, or the end of the update session. Sparkle's installer replaces the bundle, and since the app isn't running it doesn't relaunch it. When that app bundle is running (a `NSWorkspace` running application with that `bundleURL`), it does nothing and exits, leaving the update to the app's own updater and its alert. Both use the app's user defaults (`run.fc.byre.mac`), so a check by either sets Sparkle's last-check time for both.
+124 -9
View File
@@ -1,12 +1,59 @@
import AppKit
import Sparkle
import SwiftUI
@MainActor
@Observable
final class UpdateProgress {
var title = ""
var done = 0.0
var total: Double?
var detail = ""
var cancel: (() -> Void)?
}
private struct UpdateProgressView: View {
let progress: UpdateProgress
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(progress.title)
.font(Fonts.font(13, weight: 700))
.foregroundStyle(Palette.text)
if let total = progress.total {
ProgressView(value: min(progress.done, total), total: total)
.tint(Palette.accent)
} else {
ProgressView()
.progressViewStyle(.linear)
.tint(Palette.accent)
}
HStack {
Text(progress.detail)
.font(Fonts.font(12))
.foregroundStyle(Palette.muted)
Spacer()
if let cancel = progress.cancel {
Button("Cancel", action: cancel)
.buttonStyle(WebButtonStyle())
}
}
}
.padding(16)
.frame(width: 360)
.background(Palette.bg.ignoresSafeArea())
}
}
@MainActor
final class Updater: NSObject, SPUUserDriver, SPUUpdaterDelegate {
static let shared = Updater()
private var updater: SPUUpdater?
private var now = false
private var version = ""
private var staged: (item: SUAppcastItem, install: () -> Void)?
private let progress = UpdateProgress()
private var panel: NSPanel?
func start() {
let updater = SPUUpdater(hostBundle: .main, applicationBundle: .main, userDriver: self, delegate: self)
@@ -20,7 +67,8 @@ final class Updater: NSObject, SPUUserDriver, SPUUpdaterDelegate {
func check() {
if let staged {
if ask(staged.item) {
now = ask(staged.item)
if now {
staged.install()
}
return
@@ -29,6 +77,7 @@ final class Updater: NSObject, SPUUserDriver, SPUUpdaterDelegate {
}
private func ask(_ item: SUAppcastItem) -> Bool {
version = item.displayVersionString
let current = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
let alert = NSAlert()
alert.messageText = "Byre \(item.displayVersionString) is available"
@@ -47,9 +96,38 @@ final class Updater: NSObject, SPUUserDriver, SPUUpdaterDelegate {
alert.runModal()
}
private func showProgress(_ title: String, total: Double?, cancel: (() -> Void)? = nil) {
progress.title = title
progress.done = 0
progress.total = total
progress.detail = ""
progress.cancel = cancel
if panel == nil {
let p = NSPanel(contentRect: .zero, styleMask: [.titled, .fullSizeContentView], backing: .buffered, defer: false)
p.titleVisibility = .hidden
p.titlebarAppearsTransparent = true
p.isReleasedWhenClosed = false
p.contentViewController = NSHostingController(rootView: UpdateProgressView(progress: progress))
p.center()
panel = p
}
NSApp.activate()
panel?.makeKeyAndOrderFront(nil)
}
private func hideProgress() {
panel?.orderOut(nil)
panel = nil
}
private func bytes(_ n: Double) -> String {
Int64(n).formatted(.byteCount(style: .file))
}
func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool {
staged = (item, immediateInstallHandler)
if ask(item) {
now = ask(item)
if now {
immediateInstallHandler()
}
return true
@@ -80,29 +158,66 @@ final class Updater: NSObject, SPUUserDriver, SPUUpdaterDelegate {
}
func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) {
hideProgress()
tell("Update failed", error.localizedDescription)
acknowledgement()
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {}
func showDownloadInitiated(cancellation: @escaping () -> Void) {
guard now else {
return
}
showProgress("Downloading Byre \(version)", total: nil) { [weak self] in
self?.now = false
self?.hideProgress()
cancellation()
}
}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
guard expectedContentLength > 0 else {
return
}
progress.total = Double(expectedContentLength)
}
func showDownloadDidReceiveData(ofLength length: UInt64) {}
func showDownloadDidReceiveData(ofLength length: UInt64) {
progress.done += Double(length)
if let total = progress.total {
progress.detail = "\(bytes(progress.done)) of \(bytes(total))"
} else {
progress.detail = bytes(progress.done)
}
}
func showDownloadDidStartExtractingUpdate() {}
func showDownloadDidStartExtractingUpdate() {
guard now else {
return
}
showProgress("Extracting Byre \(version)", total: 1)
}
func showExtractionReceivedProgress(_ progress: Double) {}
func showExtractionReceivedProgress(_ progress: Double) {
self.progress.done = progress
}
func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
reply(now ? .install : .dismiss)
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
guard now else {
return
}
showProgress("Installing Byre \(version)", total: nil)
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
hideProgress()
acknowledgement()
}
func dismissUpdateInstallation() {}
func dismissUpdateInstallation() {
hideProgress()
}
}