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) self.updater = updater do { try updater.start() } catch { tell("Updates are off", error.localizedDescription) } } func check() { if let staged { now = ask(staged.item) if now { staged.install() } return } updater?.checkForUpdates() } 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" 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() } 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) now = ask(item) if now { 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) { hideProgress() tell("Update failed", error.localizedDescription) acknowledgement() } 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) { guard expectedContentLength > 0 else { return } progress.total = Double(expectedContentLength) } 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() { guard now else { return } showProgress("Extracting Byre \(version)", total: 1) } 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) { guard now else { return } showProgress("Installing Byre \(version)", total: nil) } func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { hideProgress() acknowledgement() } func dismissUpdateInstallation() { hideProgress() } }