diff options
| -rw-r--r-- | macos/Sources/App/macOS/AppDelegate.swift | 156 | ||||
| -rw-r--r-- | macos/Sources/Features/Terminal/TerminalView.swift | 2 | ||||
| -rw-r--r-- | macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift | 6 | ||||
| -rw-r--r-- | macos/Sources/Features/Update/UpdateBadge.swift | 32 | ||||
| -rw-r--r-- | macos/Sources/Features/Update/UpdateDriver.swift | 103 | ||||
| -rw-r--r-- | macos/Sources/Features/Update/UpdatePill.swift | 9 | ||||
| -rw-r--r-- | macos/Sources/Features/Update/UpdatePopoverView.swift | 216 | ||||
| -rw-r--r-- | macos/Sources/Features/Update/UpdateViewModel.swift | 165 |
8 files changed, 371 insertions, 318 deletions
diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index cfa871b32..4fd6dfb3f 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -103,12 +103,7 @@ class AppDelegate: NSObject, let updaterDelegate: UpdaterDelegate = UpdaterDelegate() /// Update view model for UI display - @Published private(set) var updateUIModel = UpdateViewModel() - - /// Update actions for UI interactions - private(set) lazy var updateActions: UpdateUIActions = { - createUpdateActions() - }() + @Published private(set) var updateViewModel = UpdateViewModel() /// The elapsed time since the process was started var timeSinceLaunch: TimeInterval { @@ -1013,106 +1008,83 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - // Demo mode: simulate update check instead of real Sparkle check - // TODO: Replace with real updaterController.checkForUpdates(sender) when SPUUserDriver is implemented - - // Simulate the full update check flow - updateUIModel.state = .checking - updateUIModel.progress = nil - updateUIModel.details = nil - updateUIModel.error = nil + // Demo mode: simulate update check with new UpdateState + updateViewModel.state = .checking(.init(cancel: { [weak self] in + self?.updateViewModel.state = .idle + })) - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - // Simulate finding an update - 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. New features and bug fixes would be listed here." - ) + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self else { return } + + self.updateViewModel.state = .updateAvailable(.init( + appcastItem: SUAppcastItem.empty(), + reply: { [weak self] choice in + if choice == .install { + self?.simulateDownload() + } else { + self?.updateViewModel.state = .idle + } + } + )) } } - 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 + private func simulateDownload() { + let download = UpdateState.Downloading( + cancel: { [weak self] in + self?.updateViewModel.state = .idle }, - cancel: { - print("Demo: Cancel") - self.updateUIModel.state = .idle - }, - install: { - print("Demo: Install - simulating download and install flow") + expectedLength: nil, + progress: 0, + ) + updateViewModel.state = .downloading(download) + + for i in 1...10 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { [weak self] in + let updatedDownload = UpdateState.Downloading( + cancel: download.cancel, + expectedLength: 1000, + progress: UInt64(i * 100) + ) + self?.updateViewModel.state = .downloading(updatedDownload) - self.updateUIModel.state = .downloading - self.updateUIModel.progress = 0.0 + if i == 10 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.simulateExtract() + } + } + } + } + } + + private func simulateExtract() { + updateViewModel.state = .extracting(.init(progress: 0.0)) + + for j in 1...5 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { [weak self] in + self?.updateViewModel.state = .extracting(.init(progress: Double(j) / 5.0)) - for i in 1...10 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { - self.updateUIModel.progress = Double(i) / 10.0 - - if i == 10 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.updateUIModel.state = .extracting - self.updateUIModel.progress = 0.0 - - for j in 1...5 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { - self.updateUIModel.progress = Double(j) / 5.0 - - if j == 5 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.updateUIModel.state = .readyToInstall - self.updateUIModel.progress = nil - } - } + if j == 5 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.updateViewModel.state = .readyToInstall(.init( + 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?) { _ = TerminalController.newWindow(ghostty) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 832cd7966..51c4f6ddd 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -130,7 +130,7 @@ fileprivate struct UpdateOverlay: View { HStack { Spacer() - UpdatePill(model: appDelegate.updateUIModel, actions: appDelegate.updateActions) + UpdatePill(model: appDelegate.updateViewModel) .padding(.bottom, 12) .padding(.trailing, 12) } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 6e657f33e..4737bacaf 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -101,8 +101,7 @@ class TerminalWindow: NSWindow { updateAccessory.layoutAttribute = .right updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( viewModel: viewModel, - model: appDelegate.updateUIModel, - actions: appDelegate.updateActions + model: appDelegate.updateViewModel )) addTitlebarAccessoryViewController(updateAccessory) updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false @@ -532,10 +531,9 @@ extension TerminalWindow { struct UpdateAccessoryView: View { @ObservedObject var viewModel: ViewModel @ObservedObject var model: UpdateViewModel - let actions: UpdateUIActions var body: some View { - UpdatePill(model: model, actions: actions) + UpdatePill(model: model) .padding(.top, viewModel.accessoryTopPadding) .padding(.trailing, 10) } diff --git a/macos/Sources/Features/Update/UpdateBadge.swift b/macos/Sources/Features/Update/UpdateBadge.swift index a6ffe6cb6..fd1eb3498 100644 --- a/macos/Sources/Features/Update/UpdateBadge.swift +++ b/macos/Sources/Features/Update/UpdateBadge.swift @@ -15,27 +15,35 @@ struct UpdateBadge: View { var body: some View { switch model.state { - case .downloading, .extracting: - if let progress = model.progress { + case .downloading(let download): + if let expectedLength = download.expectedLength, expectedLength > 0 { + let progress = Double(download.progress) / Double(expectedLength) ProgressRingView(progress: progress) } else { Image(systemName: "arrow.down.circle") } + case .extracting(let extracting): + ProgressRingView(progress: extracting.progress) + case .checking, .installing: - Image(systemName: model.iconName) - .rotationEffect(.degrees(rotationAngle)) - .onAppear { - withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) { - rotationAngle = 360 + if let iconName = model.iconName { + Image(systemName: iconName) + .rotationEffect(.degrees(rotationAngle)) + .onAppear { + withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) { + rotationAngle = 360 + } + } + .onDisappear { + rotationAngle = 0 } - } - .onDisappear { - rotationAngle = 0 - } + } default: - Image(systemName: model.iconName) + if let iconName = model.iconName { + Image(systemName: iconName) + } } } } diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift new file mode 100644 index 000000000..00f74e9ed --- /dev/null +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -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 + } +} diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index ea3c093dc..dda9ad607 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -5,17 +5,14 @@ struct UpdatePill: 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 - /// Whether the update popover is currently visible @State private var showPopover = false var body: some View { - if model.state != .idle { + if !model.state.isIdle { pillButton .popover(isPresented: $showPopover, arrowEdge: .bottom) { - UpdatePopoverView(model: model, actions: actions) + UpdatePopoverView(model: model) } .transition(.opacity.combined(with: .scale(scale: 0.95))) } @@ -43,6 +40,6 @@ struct UpdatePill: View { .contentShape(Capsule()) } .buttonStyle(.plain) - .help(model.stateTooltip) + .help(model.text) } } diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index af870b4de..39c4ac5c9 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -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,7 +303,6 @@ struct UpdatePopoverView: View { HStack { Spacer() Button("OK") { - actions.remindLater() dismiss() } .keyboardShortcut(.defaultAction) @@ -318,30 +311,31 @@ struct UpdatePopoverView: View { } .padding(16) } +} + +fileprivate struct UpdateErrorView: View { + let error: UpdateState.Error + let dismiss: DismissAction - /// View shown when an error occurs during the update process - private var errorView: some View { + 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(model.error?.title ?? "Update Failed") + Text("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) - } + Text(error.error.localizedDescription) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 8) { Button("OK") { - actions.remindLater() dismiss() } .keyboardShortcut(.cancelAction) @@ -350,7 +344,7 @@ struct UpdatePopoverView: View { Spacer() Button("Retry") { - actions.retry() + error.retry() dismiss() } .keyboardShortcut(.defaultAction) diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index fb477324c..57e438bcd 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -1,83 +1,13 @@ import Foundation import SwiftUI - -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 -} +import Sparkle class UpdateViewModel: ObservableObject { - @Published var state: State = .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" - } - } + @Published var state: UpdateState = .idle + /// 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 { switch state { case .idle: @@ -86,36 +16,33 @@ class UpdateViewModel: ObservableObject { return "Update Permission" case .checking: return "Checking for Updates…" - case .updateAvailable: - if let details { - return "Update Available: \(details.version)" - } - return "Update Available" - case .downloading: - if let progress { + case .updateAvailable(let update): + return "Update Available: \(update.appcastItem.displayVersionString)" + case .downloading(let download): + if let expectedLength = download.expectedLength, expectedLength > 0 { + let progress = Double(download.progress) / Double(expectedLength) return String(format: "Downloading: %.0f%%", progress * 100) } return "Downloading…" - case .extracting: - if let progress { - return String(format: "Preparing: %.0f%%", progress * 100) - } - return "Preparing…" + case .extracting(let extracting): + return String(format: "Preparing: %.0f%%", extracting.progress * 100) case .readyToInstall: return "Install Update" case .installing: return "Installing…" case .notFound: return "No Updates Available" - case .error: - return error?.title ?? "Update Failed" + case .error(let err): + 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 { case .idle: - return "" + return nil case .permissionRequest: return "questionmark.circle" case .checking: @@ -123,7 +50,7 @@ class UpdateViewModel: ObservableObject { case .updateAvailable: return "arrow.down.circle.fill" case .downloading, .extracting: - return "" // Progress ring instead + return nil case .readyToInstall: return "checkmark.circle.fill" case .installing: @@ -135,6 +62,7 @@ class UpdateViewModel: ObservableObject { } } + /// The color to apply to the icon for the current update state. var iconColor: Color { switch state { case .idle: @@ -152,6 +80,7 @@ class UpdateViewModel: ObservableObject { } } + /// The background color for the update pill. var backgroundColor: Color { switch state { case .updateAvailable: @@ -165,6 +94,7 @@ class UpdateViewModel: ObservableObject { } } + /// The foreground (text) color for the update pill. var foregroundColor: Color { switch state { 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 + } +} |
