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

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