Sparkle user driver, drives updates to the view model.

This commit is contained in:
Mitchell Hashimoto
2025-10-08 15:52:42 -07:00
parent f975ac8019
commit 59829f5359
8 changed files with 403 additions and 350 deletions

View File

@@ -103,12 +103,7 @@ class AppDelegate: NSObject,
let updaterDelegate: UpdaterDelegate = UpdaterDelegate() let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
/// Update view model for UI display /// Update view model for UI display
@Published private(set) var updateUIModel = UpdateViewModel() @Published private(set) var updateViewModel = UpdateViewModel()
/// Update actions for UI interactions
private(set) lazy var updateActions: UpdateUIActions = {
createUpdateActions()
}()
/// The elapsed time since the process was started /// The elapsed time since the process was started
var timeSinceLaunch: TimeInterval { var timeSinceLaunch: TimeInterval {
@@ -1013,106 +1008,83 @@ class AppDelegate: NSObject,
} }
@IBAction func checkForUpdates(_ sender: Any?) { @IBAction func checkForUpdates(_ sender: Any?) {
// Demo mode: simulate update check instead of real Sparkle check // Demo mode: simulate update check with new UpdateState
// TODO: Replace with real updaterController.checkForUpdates(sender) when SPUUserDriver is implemented updateViewModel.state = .checking(.init(cancel: { [weak self] in
self?.updateViewModel.state = .idle
}))
// Simulate the full update check flow DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
updateUIModel.state = .checking guard let self else { return }
updateUIModel.progress = nil
updateUIModel.details = nil
updateUIModel.error = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { self.updateViewModel.state = .updateAvailable(.init(
// Simulate finding an update appcastItem: SUAppcastItem.empty(),
self.updateUIModel.state = .updateAvailable reply: { [weak self] choice in
self.updateUIModel.details = .init( if choice == .install {
version: "1.2.0", self?.simulateDownload()
build: "demo", } else {
size: "42 MB", self?.updateViewModel.state = .idle
date: Date(), }
notesSummary: "This is a demo of the update UI. New features and bug fixes would be listed here." }
))
}
}
private func simulateDownload() {
let download = UpdateState.Downloading(
cancel: { [weak self] in
self?.updateViewModel.state = .idle
},
expectedLength: nil,
progress: 0,
) )
} updateViewModel.state = .downloading(download)
}
private func createUpdateActions() -> UpdateUIActions {
return UpdateUIActions(
allowAutoChecks: {
print("Demo: Allow auto checks")
self.updateUIModel.state = .idle
},
denyAutoChecks: {
print("Demo: Deny auto checks")
self.updateUIModel.state = .idle
},
cancel: {
print("Demo: Cancel")
self.updateUIModel.state = .idle
},
install: {
print("Demo: Install - simulating download and install flow")
self.updateUIModel.state = .downloading
self.updateUIModel.progress = 0.0
for i in 1...10 { for i in 1...10 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { [weak self] in
self.updateUIModel.progress = Double(i) / 10.0 let updatedDownload = UpdateState.Downloading(
cancel: download.cancel,
expectedLength: 1000,
progress: UInt64(i * 100)
)
self?.updateViewModel.state = .downloading(updatedDownload)
if i == 10 { if i == 10 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self.updateUIModel.state = .extracting self?.simulateExtract()
self.updateUIModel.progress = 0.0 }
}
}
}
}
private func simulateExtract() {
updateViewModel.state = .extracting(.init(progress: 0.0))
for j in 1...5 { for j in 1...5 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { [weak self] in
self.updateUIModel.progress = Double(j) / 5.0 self?.updateViewModel.state = .extracting(.init(progress: Double(j) / 5.0))
if j == 5 { if j == 5 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self.updateUIModel.state = .readyToInstall self?.updateViewModel.state = .readyToInstall(.init(
self.updateUIModel.progress = nil reply: { [weak self] choice in
if choice == .install {
self?.updateViewModel.state = .installing
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
self?.updateViewModel.state = .idle
}
} else {
self?.updateViewModel.state = .idle
}
}
))
} }
} }
} }
} }
} }
}
}
}
},
remindLater: {
print("Demo: Remind later")
self.updateUIModel.state = .idle
},
skipThisVersion: {
print("Demo: Skip version")
self.updateUIModel.state = .idle
},
showReleaseNotes: {
print("Demo: Show release notes")
guard let url = URL(string: "https://github.com/ghostty-org/ghostty/releases") else { return }
NSWorkspace.shared.open(url)
},
retry: {
print("Demo: Retry - simulating update check")
self.updateUIModel.state = .checking
self.updateUIModel.progress = nil
self.updateUIModel.error = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.updateUIModel.state = .updateAvailable
self.updateUIModel.details = .init(
version: "1.2.0",
build: "demo",
size: "42 MB",
date: Date(),
notesSummary: "This is a demo of the update UI."
)
}
}
)
}
@IBAction func newWindow(_ sender: Any?) { @IBAction func newWindow(_ sender: Any?) {
_ = TerminalController.newWindow(ghostty) _ = TerminalController.newWindow(ghostty)

View File

@@ -130,7 +130,7 @@ fileprivate struct UpdateOverlay: View {
HStack { HStack {
Spacer() Spacer()
UpdatePill(model: appDelegate.updateUIModel, actions: appDelegate.updateActions) UpdatePill(model: appDelegate.updateViewModel)
.padding(.bottom, 12) .padding(.bottom, 12)
.padding(.trailing, 12) .padding(.trailing, 12)
} }

View File

@@ -101,8 +101,7 @@ class TerminalWindow: NSWindow {
updateAccessory.layoutAttribute = .right updateAccessory.layoutAttribute = .right
updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView(
viewModel: viewModel, viewModel: viewModel,
model: appDelegate.updateUIModel, model: appDelegate.updateViewModel
actions: appDelegate.updateActions
)) ))
addTitlebarAccessoryViewController(updateAccessory) addTitlebarAccessoryViewController(updateAccessory)
updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false
@@ -532,10 +531,9 @@ extension TerminalWindow {
struct UpdateAccessoryView: View { struct UpdateAccessoryView: View {
@ObservedObject var viewModel: ViewModel @ObservedObject var viewModel: ViewModel
@ObservedObject var model: UpdateViewModel @ObservedObject var model: UpdateViewModel
let actions: UpdateUIActions
var body: some View { var body: some View {
UpdatePill(model: model, actions: actions) UpdatePill(model: model)
.padding(.top, viewModel.accessoryTopPadding) .padding(.top, viewModel.accessoryTopPadding)
.padding(.trailing, 10) .padding(.trailing, 10)
} }

View File

@@ -15,15 +15,20 @@ struct UpdateBadge: View {
var body: some View { var body: some View {
switch model.state { switch model.state {
case .downloading, .extracting: case .downloading(let download):
if let progress = model.progress { if let expectedLength = download.expectedLength, expectedLength > 0 {
let progress = Double(download.progress) / Double(expectedLength)
ProgressRingView(progress: progress) ProgressRingView(progress: progress)
} else { } else {
Image(systemName: "arrow.down.circle") Image(systemName: "arrow.down.circle")
} }
case .extracting(let extracting):
ProgressRingView(progress: extracting.progress)
case .checking, .installing: case .checking, .installing:
Image(systemName: model.iconName) if let iconName = model.iconName {
Image(systemName: iconName)
.rotationEffect(.degrees(rotationAngle)) .rotationEffect(.degrees(rotationAngle))
.onAppear { .onAppear {
withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) { withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) {
@@ -33,9 +38,12 @@ struct UpdateBadge: View {
.onDisappear { .onDisappear {
rotationAngle = 0 rotationAngle = 0
} }
}
default: default:
Image(systemName: model.iconName) if let iconName = model.iconName {
Image(systemName: iconName)
}
} }
} }
} }

View File

@@ -0,0 +1,103 @@
import Sparkle
/// Implement the SPUUserDriver to modify our UpdateViewModel for custom presentation.
class UpdateDriver: NSObject, SPUUserDriver {
let viewModel: UpdateViewModel
let retryHandler: () -> Void
init(viewModel: UpdateViewModel, retryHandler: @escaping () -> Void) {
self.viewModel = viewModel
self.retryHandler = retryHandler
super.init()
}
func show(_ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
viewModel.state = .permissionRequest(.init(request: request, reply: reply))
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
viewModel.state = .checking(.init(cancel: cancellation))
}
func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply))
}
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
// We don't do anything with the release notes here because Ghostty
// doesn't use the release notes feature of Sparkle currently.
}
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) {
// We don't do anything with release notes. See `showUpdateReleaseNotes`
}
func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) {
viewModel.state = .notFound
// TODO: Do we need to acknowledge?
}
func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) {
viewModel.state = .error(.init(error: error, retry: retryHandler))
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {
viewModel.state = .downloading(.init(
cancel: cancellation,
expectedLength: nil,
progress: 0))
}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
guard case let .downloading(downloading) = viewModel.state else {
return
}
viewModel.state = .downloading(.init(
cancel: downloading.cancel,
expectedLength: expectedContentLength,
progress: 0))
}
func showDownloadDidReceiveData(ofLength length: UInt64) {
guard case let .downloading(downloading) = viewModel.state else {
return
}
viewModel.state = .downloading(.init(
cancel: downloading.cancel,
expectedLength: downloading.expectedLength,
progress: downloading.progress + length))
}
func showDownloadDidStartExtractingUpdate() {
viewModel.state = .extracting(.init(progress: 0))
}
func showExtractionReceivedProgress(_ progress: Double) {
viewModel.state = .extracting(.init(progress: progress))
}
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
viewModel.state = .readyToInstall(.init(reply: reply))
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
viewModel.state = .installing
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
// We don't do anything here.
viewModel.state = .idle
}
func showUpdateInFocus() {
// We don't currently implement this because our update state is
// shown in a terminal window. We may want to implement this at some
// point to handle the case that no windows are open, though.
}
func dismissUpdateInstallation() {
viewModel.state = .idle
}
}

View File

@@ -5,17 +5,14 @@ struct UpdatePill: View {
/// The update view model that provides the current state and information /// The update view model that provides the current state and information
@ObservedObject var model: UpdateViewModel @ObservedObject var model: UpdateViewModel
/// The actions that can be performed on updates
let actions: UpdateUIActions
/// Whether the update popover is currently visible /// Whether the update popover is currently visible
@State private var showPopover = false @State private var showPopover = false
var body: some View { var body: some View {
if model.state != .idle { if !model.state.isIdle {
pillButton pillButton
.popover(isPresented: $showPopover, arrowEdge: .bottom) { .popover(isPresented: $showPopover, arrowEdge: .bottom) {
UpdatePopoverView(model: model, actions: actions) UpdatePopoverView(model: model)
} }
.transition(.opacity.combined(with: .scale(scale: 0.95))) .transition(.opacity.combined(with: .scale(scale: 0.95)))
} }
@@ -43,6 +40,6 @@ struct UpdatePill: View {
.contentShape(Capsule()) .contentShape(Capsule())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help(model.stateTooltip) .help(model.text)
} }
} }

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Sparkle
/// A popover view that displays detailed update information and action buttons. /// A popover view that displays detailed update information and action buttons.
/// ///
@@ -8,9 +9,6 @@ struct UpdatePopoverView: View {
/// The update view model that provides the current state and information /// The update view model that provides the current state and information
@ObservedObject var model: UpdateViewModel @ObservedObject var model: UpdateViewModel
/// The actions that can be performed on updates
let actions: UpdateUIActions
/// Environment value for dismissing the popover /// Environment value for dismissing the popover
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -18,41 +16,47 @@ struct UpdatePopoverView: View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
switch model.state { switch model.state {
case .idle: case .idle:
// Shouldn't happen in a well-formed view stack. Higher levels
// should not call the popover for idles.
EmptyView() EmptyView()
case .permissionRequest: case .permissionRequest(let request):
permissionRequestView PermissionRequestView(request: request, dismiss: dismiss)
case .checking: case .checking(let checking):
checkingView CheckingView(checking: checking, dismiss: dismiss)
case .updateAvailable: case .updateAvailable(let update):
updateAvailableView UpdateAvailableView(update: update, dismiss: dismiss)
case .downloading: case .downloading(let download):
downloadingView DownloadingView(download: download, dismiss: dismiss)
case .extracting: case .extracting(let extracting):
extractingView ExtractingView(extracting: extracting)
case .readyToInstall: case .readyToInstall(let ready):
readyToInstallView ReadyToInstallView(ready: ready, dismiss: dismiss)
case .installing: case .installing:
installingView InstallingView()
case .notFound: case .notFound:
notFoundView NotFoundView(dismiss: dismiss)
case .error: case .error(let error):
errorView UpdateErrorView(error: error, dismiss: dismiss)
} }
} }
.frame(width: 300) .frame(width: 300)
} }
}
/// View shown when requesting permission to enable automatic updates fileprivate struct PermissionRequestView: View {
private var permissionRequestView: some View { let request: UpdateState.PermissionRequest
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Enable automatic updates?") Text("Enable automatic updates?")
@@ -66,7 +70,9 @@ struct UpdatePopoverView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Button("Not Now") { Button("Not Now") {
actions.denyAutoChecks() request.reply(SUUpdatePermissionResponse(
automaticUpdateChecks: false,
sendSystemProfile: false))
dismiss() dismiss()
} }
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
@@ -74,7 +80,9 @@ struct UpdatePopoverView: View {
Spacer() Spacer()
Button("Allow") { Button("Allow") {
actions.allowAutoChecks() request.reply(SUUpdatePermissionResponse(
automaticUpdateChecks: true,
sendSystemProfile: false))
dismiss() dismiss()
} }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
@@ -83,9 +91,13 @@ struct UpdatePopoverView: View {
} }
.padding(16) .padding(16)
} }
}
/// View shown while checking for updates fileprivate struct CheckingView: View {
private var checkingView: some View { let checking: UpdateState.Checking
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 10) { HStack(spacing: 10) {
ProgressView() ProgressView()
@@ -97,7 +109,7 @@ struct UpdatePopoverView: View {
HStack { HStack {
Spacer() Spacer()
Button("Cancel") { Button("Cancel") {
actions.cancel() checking.cancel()
dismiss() dismiss()
} }
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
@@ -106,47 +118,39 @@ struct UpdatePopoverView: View {
} }
.padding(16) .padding(16)
} }
}
/// View shown when an update is available, displaying version and size information fileprivate struct UpdateAvailableView: View {
private var updateAvailableView: some View { let update: UpdateState.UpdateAvailable
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Update Available") Text("Update Available")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
if let details = model.details {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) { HStack(spacing: 6) {
Text("Version:") Text("Version:")
.foregroundColor(.secondary) .foregroundColor(.secondary)
.frame(width: 50, alignment: .trailing) .frame(width: 50, alignment: .trailing)
Text(details.version) Text(update.appcastItem.displayVersionString)
} }
.font(.system(size: 11)) .font(.system(size: 11))
if let size = details.size {
HStack(spacing: 6) {
Text("Size:")
.foregroundColor(.secondary)
.frame(width: 50, alignment: .trailing)
Text(size)
}
.font(.system(size: 11))
}
}
} }
} }
HStack(spacing: 8) { HStack(spacing: 8) {
Button("Skip") { Button("Skip") {
actions.skipThisVersion() update.reply(.skip)
dismiss() dismiss()
} }
.controlSize(.small) .controlSize(.small)
Button("Later") { Button("Later") {
actions.remindLater() update.reply(.dismiss)
dismiss() dismiss()
} }
.controlSize(.small) .controlSize(.small)
@@ -155,7 +159,7 @@ struct UpdatePopoverView: View {
Spacer() Spacer()
Button("Install") { Button("Install") {
actions.install() update.reply(.install)
dismiss() dismiss()
} }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
@@ -164,36 +168,22 @@ struct UpdatePopoverView: View {
} }
} }
.padding(16) .padding(16)
}
}
}
if model.details?.notesSummary != nil { fileprivate struct DownloadingView: View {
Divider() let download: UpdateState.Downloading
let dismiss: DismissAction
Button(action: actions.showReleaseNotes) { var body: some View {
HStack {
Text("View Release Notes")
.font(.system(size: 11))
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.system(size: 11))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color(nsColor: .controlBackgroundColor))
}
}
}
/// View shown while downloading an update, with progress indicator
private var downloadingView: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Downloading Update") Text("Downloading Update")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
if let progress = model.progress { if let expectedLength = download.expectedLength, expectedLength > 0 {
let progress = Double(download.progress) / Double(expectedLength)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
ProgressView(value: progress) ProgressView(value: progress)
Text(String(format: "%.0f%%", progress * 100)) Text(String(format: "%.0f%%", progress * 100))
@@ -209,7 +199,7 @@ struct UpdatePopoverView: View {
HStack { HStack {
Spacer() Spacer()
Button("Cancel") { Button("Cancel") {
actions.cancel() download.cancel()
dismiss() dismiss()
} }
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
@@ -218,45 +208,45 @@ struct UpdatePopoverView: View {
} }
.padding(16) .padding(16)
} }
}
/// View shown while extracting/preparing the downloaded update fileprivate struct ExtractingView: View {
private var extractingView: some View { let extracting: UpdateState.Extracting
var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Preparing Update") Text("Preparing Update")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
if let progress = model.progress {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
ProgressView(value: progress) ProgressView(value: extracting.progress, total: 1.0)
Text(String(format: "%.0f%%", progress * 100)) Text(String(format: "%.0f%%", extracting.progress * 100))
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} else {
ProgressView()
.controlSize(.small)
}
} }
.padding(16) .padding(16)
} }
}
/// View shown when an update is ready to be installed fileprivate struct ReadyToInstallView: View {
private var readyToInstallView: some View { let ready: UpdateState.ReadyToInstall
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Ready to Install") Text("Ready to Install")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
if let details = model.details { Text("The update is ready to install.")
Text("Version \(details.version) is ready to install.")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
}
HStack(spacing: 8) { HStack(spacing: 8) {
Button("Later") { Button("Later") {
actions.remindLater() ready.reply(.dismiss)
dismiss() dismiss()
} }
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
@@ -265,7 +255,7 @@ struct UpdatePopoverView: View {
Spacer() Spacer()
Button("Install and Relaunch") { Button("Install and Relaunch") {
actions.install() ready.reply(.install)
dismiss() dismiss()
} }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
@@ -275,9 +265,10 @@ struct UpdatePopoverView: View {
} }
.padding(16) .padding(16)
} }
}
/// View shown during the installation process fileprivate struct InstallingView: View {
private var installingView: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) { HStack(spacing: 10) {
ProgressView() ProgressView()
@@ -292,9 +283,12 @@ struct UpdatePopoverView: View {
} }
.padding(16) .padding(16)
} }
}
/// View shown when no updates are found (already on latest version) fileprivate struct NotFoundView: View {
private var notFoundView: some View { let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("No Updates Found") Text("No Updates Found")
@@ -309,48 +303,48 @@ struct UpdatePopoverView: View {
HStack { HStack {
Spacer() Spacer()
Button("OK") { Button("OK") {
actions.remindLater() dismiss()
dismiss() }
} .keyboardShortcut(.defaultAction)
.keyboardShortcut(.defaultAction) .controlSize(.small)
.controlSize(.small) }
} }
} .padding(16)
.padding(16) }
} }
/// View shown when an error occurs during the update process fileprivate struct UpdateErrorView: View {
private var errorView: some View { let error: UpdateState.Error
VStack(alignment: .leading, spacing: 16) { let dismiss: DismissAction
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { var body: some View {
Image(systemName: "exclamationmark.triangle.fill") VStack(alignment: .leading, spacing: 16) {
.foregroundColor(.orange) VStack(alignment: .leading, spacing: 8) {
.font(.system(size: 13)) HStack(spacing: 8) {
Text(model.error?.title ?? "Update Failed") Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 13, weight: .semibold)) .foregroundColor(.orange)
} .font(.system(size: 13))
Text("Update Failed")
if let message = model.error?.message { .font(.system(size: 13, weight: .semibold))
Text(message) }
.font(.system(size: 11))
.foregroundColor(.secondary) Text(error.error.localizedDescription)
.fixedSize(horizontal: false, vertical: true) .font(.system(size: 11))
} .foregroundColor(.secondary)
} .fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8) {
Button("OK") { HStack(spacing: 8) {
actions.remindLater() Button("OK") {
dismiss() dismiss()
} }
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
.controlSize(.small) .controlSize(.small)
Spacer() Spacer()
Button("Retry") { Button("Retry") {
actions.retry() error.retry()
dismiss() dismiss()
} }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)

View File

@@ -1,83 +1,13 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import Sparkle
struct UpdateUIActions {
let allowAutoChecks: () -> Void
let denyAutoChecks: () -> Void
let cancel: () -> Void
let install: () -> Void
let remindLater: () -> Void
let skipThisVersion: () -> Void
let showReleaseNotes: () -> Void
let retry: () -> Void
}
class UpdateViewModel: ObservableObject { class UpdateViewModel: ObservableObject {
@Published var state: State = .idle @Published var state: UpdateState = .idle
@Published var progress: Double? = nil
@Published var details: Details? = nil
@Published var error: ErrorInfo? = nil
enum State: Equatable {
case idle
case permissionRequest
case checking
case updateAvailable
case downloading
case extracting
case readyToInstall
case installing
case notFound
case error
}
struct ErrorInfo: Equatable {
let title: String
let message: String
}
struct Details: Equatable {
let version: String
let build: String?
let size: String?
let date: Date?
let notesSummary: String?
}
var stateTooltip: String {
switch state {
case .idle:
return ""
case .permissionRequest:
return "Update permission required"
case .checking:
return "Checking for updates…"
case .updateAvailable:
if let details {
return "Update available: \(details.version)"
}
return "Update available"
case .downloading:
if let progress {
return String(format: "Downloading %.0f%%…", progress * 100)
}
return "Downloading…"
case .extracting:
if let progress {
return String(format: "Preparing %.0f%%…", progress * 100)
}
return "Preparing…"
case .readyToInstall:
return "Ready to install"
case .installing:
return "Installing…"
case .notFound:
return "No updates found"
case .error:
return error?.title ?? "Update failed"
}
}
/// The text to display for the current update state.
/// Returns an empty string for idle state, progress percentages for downloading/extracting,
/// or descriptive text for other states.
var text: String { var text: String {
switch state { switch state {
case .idle: case .idle:
@@ -86,36 +16,33 @@ class UpdateViewModel: ObservableObject {
return "Update Permission" return "Update Permission"
case .checking: case .checking:
return "Checking for Updates…" return "Checking for Updates…"
case .updateAvailable: case .updateAvailable(let update):
if let details { return "Update Available: \(update.appcastItem.displayVersionString)"
return "Update Available: \(details.version)" case .downloading(let download):
} if let expectedLength = download.expectedLength, expectedLength > 0 {
return "Update Available" let progress = Double(download.progress) / Double(expectedLength)
case .downloading:
if let progress {
return String(format: "Downloading: %.0f%%", progress * 100) return String(format: "Downloading: %.0f%%", progress * 100)
} }
return "Downloading…" return "Downloading…"
case .extracting: case .extracting(let extracting):
if let progress { return String(format: "Preparing: %.0f%%", extracting.progress * 100)
return String(format: "Preparing: %.0f%%", progress * 100)
}
return "Preparing…"
case .readyToInstall: case .readyToInstall:
return "Install Update" return "Install Update"
case .installing: case .installing:
return "Installing…" return "Installing…"
case .notFound: case .notFound:
return "No Updates Available" return "No Updates Available"
case .error: case .error(let err):
return error?.title ?? "Update Failed" return err.error.localizedDescription
} }
} }
var iconName: String { /// The SF Symbol icon name for the current update state.
/// Returns nil for idle, downloading, and extracting states.
var iconName: String? {
switch state { switch state {
case .idle: case .idle:
return "" return nil
case .permissionRequest: case .permissionRequest:
return "questionmark.circle" return "questionmark.circle"
case .checking: case .checking:
@@ -123,7 +50,7 @@ class UpdateViewModel: ObservableObject {
case .updateAvailable: case .updateAvailable:
return "arrow.down.circle.fill" return "arrow.down.circle.fill"
case .downloading, .extracting: case .downloading, .extracting:
return "" // Progress ring instead return nil
case .readyToInstall: case .readyToInstall:
return "checkmark.circle.fill" return "checkmark.circle.fill"
case .installing: case .installing:
@@ -135,6 +62,7 @@ class UpdateViewModel: ObservableObject {
} }
} }
/// The color to apply to the icon for the current update state.
var iconColor: Color { var iconColor: Color {
switch state { switch state {
case .idle: case .idle:
@@ -152,6 +80,7 @@ class UpdateViewModel: ObservableObject {
} }
} }
/// The background color for the update pill.
var backgroundColor: Color { var backgroundColor: Color {
switch state { switch state {
case .updateAvailable: case .updateAvailable:
@@ -165,6 +94,7 @@ class UpdateViewModel: ObservableObject {
} }
} }
/// The foreground (text) color for the update pill.
var foregroundColor: Color { var foregroundColor: Color {
switch state { switch state {
case .updateAvailable, .readyToInstall: case .updateAvailable, .readyToInstall:
@@ -176,3 +106,54 @@ class UpdateViewModel: ObservableObject {
} }
} }
} }
enum UpdateState {
case idle
case permissionRequest(PermissionRequest)
case checking(Checking)
case updateAvailable(UpdateAvailable)
case notFound
case error(Error)
case downloading(Downloading)
case extracting(Extracting)
case readyToInstall(ReadyToInstall)
case installing
var isIdle: Bool {
if case .idle = self { return true }
return false
}
struct PermissionRequest {
let request: SPUUpdatePermissionRequest
let reply: @Sendable (SUUpdatePermissionResponse) -> Void
}
struct Checking {
let cancel: () -> Void
}
struct UpdateAvailable {
let appcastItem: SUAppcastItem
let reply: @Sendable (SPUUserUpdateChoice) -> Void
}
struct Error {
let error: any Swift.Error
let retry: () -> Void
}
struct Downloading {
let cancel: () -> Void
let expectedLength: UInt64?
let progress: UInt64
}
struct Extracting {
let progress: Double
}
struct ReadyToInstall {
let reply: @Sendable (SPUUserUpdateChoice) -> Void
}
}