summaryrefslogtreecommitdiff
path: root/macos/Sources
diff options
context:
space:
mode:
Diffstat (limited to 'macos/Sources')
-rw-r--r--macos/Sources/App/macOS/AppDelegate.swift156
-rw-r--r--macos/Sources/Features/Terminal/TerminalView.swift2
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift6
-rw-r--r--macos/Sources/Features/Update/UpdateBadge.swift32
-rw-r--r--macos/Sources/Features/Update/UpdateDriver.swift103
-rw-r--r--macos/Sources/Features/Update/UpdatePill.swift9
-rw-r--r--macos/Sources/Features/Update/UpdatePopoverView.swift216
-rw-r--r--macos/Sources/Features/Update/UpdateViewModel.swift165
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
+ }
+}