Files

224 lines
7.0 KiB
Swift
Raw Permalink Normal View History

import AppKit
import Sparkle
2026-10-19 19:36:42 -07:00
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
2026-10-19 19:36:42 -07:00
private var version = ""
private var staged: (item: SUAppcastItem, install: () -> Void)?
2026-10-19 19:36:42 -07:00
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 {
2026-10-19 19:36:42 -07:00
now = ask(staged.item)
if now {
staged.install()
}
return
}
updater?.checkForUpdates()
}
private func ask(_ item: SUAppcastItem) -> Bool {
2026-10-19 19:36:42 -07:00
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()
}
2026-10-19 19:36:42 -07:00
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)
2026-10-19 19:36:42 -07:00
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) {
2026-10-19 19:36:42 -07:00
hideProgress()
tell("Update failed", error.localizedDescription)
acknowledgement()
}
2026-10-19 19:36:42 -07:00
func showDownloadInitiated(cancellation: @escaping () -> Void) {
guard now else {
return
}
showProgress("Downloading Byre \(version)", total: nil) { [weak self] in
self?.now = false
self?.hideProgress()
cancellation()
}
}
2026-10-19 19:36:42 -07:00
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
guard expectedContentLength > 0 else {
return
}
progress.total = Double(expectedContentLength)
}
2026-10-19 19:36:42 -07:00
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)
}
}
2026-10-19 19:36:42 -07:00
func showDownloadDidStartExtractingUpdate() {
guard now else {
return
}
showProgress("Extracting Byre \(version)", total: 1)
}
2026-10-19 19:36:42 -07:00
func showExtractionReceivedProgress(_ progress: Double) {
self.progress.done = progress
}
func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
reply(now ? .install : .dismiss)
}
2026-10-19 19:36:42 -07:00
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) {
2026-10-19 19:36:42 -07:00
hideProgress()
acknowledgement()
}
2026-10-19 19:36:42 -07:00
func dismissUpdateInstallation() {
hideProgress()
}
}