From 52a231e635ef4a9b6d398e5a5155c0306967cc67 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Mon, 19 Oct 2026 19:36:42 -0700 Subject: [PATCH] update progress --- docs/macos.md | 2 +- mac/Sources/Byre/Updater.swift | 133 ++++++++++++++++++++++++++++++--- 2 files changed, 125 insertions(+), 10 deletions(-) diff --git a/docs/macos.md b/docs/macos.md index 226e02e..8212dee 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -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 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 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. diff --git a/mac/Sources/Byre/Updater.swift b/mac/Sources/Byre/Updater.swift index 1f2ed3c..9ca7ddf 100644 --- a/mac/Sources/Byre/Updater.swift +++ b/mac/Sources/Byre/Updater.swift @@ -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() + } }