mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-10-17 07:16:12 +00:00
Sparkle user driver, drives updates to the view model.
This commit is contained in:
@@ -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)
|
||||||
|
@@ -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)
|
||||||
}
|
}
|
||||||
|
@@ -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)
|
||||||
}
|
}
|
||||||
|
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
103
macos/Sources/Features/Update/UpdateDriver.swift
Normal file
103
macos/Sources/Features/Update/UpdateDriver.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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)
|
||||||
|
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user