summaryrefslogtreecommitdiff
path: root/macos
diff options
context:
space:
mode:
authorMitchell Hashimoto <m@mitchellh.com>2025-10-08 08:43:39 -0700
committerMitchell Hashimoto <m@mitchellh.com>2025-10-08 12:50:09 -0700
commit09ba5a27a234bfe5c8cad1c51da7b5028f239f1b (patch)
tree020eb363959e363bbfe0c252386bfe4d50fe0ac8 /macos
parent67ece534237ea5ebf99f2fe5628c56d52e3291f3 (diff)
macOS: Unobtrusive update views
Diffstat (limited to 'macos')
-rw-r--r--macos/Sources/App/macOS/AppDelegate.swift30
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift140
-rw-r--r--macos/Sources/Features/Update/UpdateBadge.swift65
-rw-r--r--macos/Sources/Features/Update/UpdatePill.swift51
-rw-r--r--macos/Sources/Features/Update/UpdatePopoverView.swift362
-rw-r--r--macos/Sources/Features/Update/UpdateViewModel.swift178
6 files changed, 814 insertions, 12 deletions
diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift
index 942aecdd4..a893c3877 100644
--- a/macos/Sources/App/macOS/AppDelegate.swift
+++ b/macos/Sources/App/macOS/AppDelegate.swift
@@ -1,4 +1,5 @@
import AppKit
+import SwiftUI
import UserNotifications
import OSLog
import Sparkle
@@ -1004,7 +1005,34 @@ class AppDelegate: NSObject,
}
@IBAction func checkForUpdates(_ sender: Any?) {
- updaterController.checkForUpdates(sender)
+ // Demo mode: simulate update check instead of real Sparkle check
+ // TODO: Replace with real updaterController.checkForUpdates(sender) when SPUUserDriver is implemented
+
+ guard let terminalWindow = NSApp.keyWindow as? TerminalWindow else {
+ // Fallback to real update check if no terminal window
+ updaterController.checkForUpdates(sender)
+ return
+ }
+
+ let model = terminalWindow.updateUIModel
+
+ // Simulate the full update check flow
+ model.state = .checking
+ model.progress = nil
+ model.details = nil
+ model.error = nil
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ // Simulate finding an update
+ model.state = .updateAvailable
+ model.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."
+ )
+ }
}
@IBAction func newWindow(_ sender: Any?) {
diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift
index 3ab6293dc..248577f4f 100644
--- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift
+++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift
@@ -14,6 +14,10 @@ class TerminalWindow: NSWindow {
/// Reset split zoom button in titlebar
private let resetZoomAccessory = NSTitlebarAccessoryViewController()
+
+ /// Update notification UI in titlebar
+ private let updateAccessory = NSTitlebarAccessoryViewController()
+ private(set) var updateUIModel = UpdateViewModel()
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private(set) var derivedConfig: DerivedConfig = .init()
@@ -85,6 +89,16 @@ class TerminalWindow: NSWindow {
}))
addTitlebarAccessoryViewController(resetZoomAccessory)
resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
+
+ // Create update notification accessory
+ updateAccessory.layoutAttribute = .right
+ updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView(
+ viewModel: viewModel,
+ model: updateUIModel,
+ actions: createUpdateActions()
+ ))
+ addTitlebarAccessoryViewController(updateAccessory)
+ updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false
}
// Setup the accessory view for tabs that shows our keyboard shortcuts,
@@ -198,6 +212,9 @@ class TerminalWindow: NSWindow {
if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
removeTitlebarAccessoryViewController(at: idx)
}
+
+ // We don't need to do this with the update accessory. I don't know why but
+ // everything works fine.
}
private func tabBarDidDisappear() {
@@ -436,6 +453,94 @@ class TerminalWindow: NSWindow {
standardWindowButton(.miniaturizeButton)?.isHidden = true
standardWindowButton(.zoomButton)?.isHidden = true
}
+
+ // MARK: Update UI
+
+ private func createUpdateActions() -> UpdateUIActions {
+ UpdateUIActions(
+ allowAutoChecks: { [weak self] in
+ print("Demo: Allow auto checks")
+ self?.updateUIModel.state = .idle
+ },
+ denyAutoChecks: { [weak self] in
+ print("Demo: Deny auto checks")
+ self?.updateUIModel.state = .idle
+ },
+ cancel: { [weak self] in
+ print("Demo: Cancel")
+ self?.updateUIModel.state = .idle
+ },
+ install: { [weak self] in
+ guard let self else { return }
+ print("Demo: Install - simulating download and install flow")
+
+ // Start downloading
+ self.updateUIModel.state = .downloading
+ self.updateUIModel.progress = 0.0
+
+ // Simulate download progress
+ for i in 1...10 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) {
+ self.updateUIModel.progress = Double(i) / 10.0
+
+ if i == 10 {
+ // Move to extraction
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ self.updateUIModel.state = .extracting
+ self.updateUIModel.progress = 0.0
+
+ // Simulate extraction progress
+ for j in 1...5 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) {
+ self.updateUIModel.progress = Double(j) / 5.0
+
+ if j == 5 {
+ // Move to ready to install
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ self.updateUIModel.state = .readyToInstall
+ self.updateUIModel.progress = nil
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ remindLater: { [weak self] in
+ print("Demo: Remind later")
+ self?.updateUIModel.state = .idle
+ },
+ skipThisVersion: { [weak self] in
+ print("Demo: Skip version")
+ self?.updateUIModel.state = .idle
+ },
+ showReleaseNotes: { [weak self] in
+ print("Demo: Show release notes")
+ guard let url = URL(string: "https://github.com/ghostty-org/ghostty/releases") else { return }
+ NSWorkspace.shared.open(url)
+ },
+ retry: { [weak self] in
+ guard let self else { return }
+ 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."
+ )
+ }
+ }
+ )
+ }
// MARK: Config
@@ -467,21 +572,20 @@ extension TerminalWindow {
class ViewModel: ObservableObject {
@Published var isSurfaceZoomed: Bool = false
@Published var hasToolbar: Bool = false
- }
-
- struct ResetZoomAccessoryView: View {
- @ObservedObject var viewModel: ViewModel
- let action: () -> Void
- // The padding from the top that the view appears. This was all just manually
- // measured based on the OS.
- var topPadding: CGFloat {
+ /// Calculates the top padding based on toolbar visibility and macOS version
+ fileprivate var accessoryTopPadding: CGFloat {
if #available(macOS 26.0, *) {
- return viewModel.hasToolbar ? 10 : 5
+ return hasToolbar ? 10 : 5
} else {
- return viewModel.hasToolbar ? 9 : 4
+ return hasToolbar ? 9 : 4
}
}
+ }
+
+ struct ResetZoomAccessoryView: View {
+ @ObservedObject var viewModel: ViewModel
+ let action: () -> Void
var body: some View {
if viewModel.isSurfaceZoomed {
@@ -497,10 +601,24 @@ extension TerminalWindow {
}
// With a toolbar, the window title is taller, so we need more padding
// to properly align.
- .padding(.top, topPadding)
+ .padding(.top, viewModel.accessoryTopPadding)
// We always need space at the end of the titlebar
.padding(.trailing, 10)
}
}
}
+
+ /// A pill-shaped button that displays update status and provides access to update actions.
+ struct UpdateAccessoryView: View {
+ @ObservedObject var viewModel: ViewModel
+ @ObservedObject var model: UpdateViewModel
+ let actions: UpdateUIActions
+
+ var body: some View {
+ UpdatePill(model: model, actions: actions)
+ .padding(.top, viewModel.accessoryTopPadding)
+ .padding(.trailing, 10)
+ }
+ }
+
}
diff --git a/macos/Sources/Features/Update/UpdateBadge.swift b/macos/Sources/Features/Update/UpdateBadge.swift
new file mode 100644
index 000000000..a6ffe6cb6
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdateBadge.swift
@@ -0,0 +1,65 @@
+import SwiftUI
+
+/// A badge view that displays the current state of an update operation.
+///
+/// Shows different visual indicators based on the update state:
+/// - Progress ring for downloading/extracting with progress
+/// - Animated rotating icon for checking/installing
+/// - Static icon for other states
+struct UpdateBadge: View {
+ /// The update view model that provides the current state and progress
+ @ObservedObject var model: UpdateViewModel
+
+ /// Current rotation angle for animated icon states
+ @State private var rotationAngle: Double = 0
+
+ var body: some View {
+ switch model.state {
+ case .downloading, .extracting:
+ if let progress = model.progress {
+ ProgressRingView(progress: progress)
+ } else {
+ Image(systemName: "arrow.down.circle")
+ }
+
+ case .checking, .installing:
+ Image(systemName: model.iconName)
+ .rotationEffect(.degrees(rotationAngle))
+ .onAppear {
+ withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) {
+ rotationAngle = 360
+ }
+ }
+ .onDisappear {
+ rotationAngle = 0
+ }
+
+ default:
+ Image(systemName: model.iconName)
+ }
+ }
+}
+
+/// A circular progress indicator with a stroke-based ring design.
+///
+/// Displays a partially filled circle that represents progress from 0.0 to 1.0.
+fileprivate struct ProgressRingView: View {
+ /// The current progress value, ranging from 0.0 (empty) to 1.0 (complete)
+ let progress: Double
+
+ /// The width of the progress ring stroke
+ let lineWidth: CGFloat = 2
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .stroke(Color.primary.opacity(0.2), lineWidth: lineWidth)
+
+ Circle()
+ .trim(from: 0, to: progress)
+ .stroke(Color.primary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ .animation(.easeInOut(duration: 0.2), value: progress)
+ }
+ }
+}
diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift
new file mode 100644
index 000000000..604be0fbc
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdatePill.swift
@@ -0,0 +1,51 @@
+import SwiftUI
+
+/// A pill-shaped button that displays update status and provides access to update actions.
+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 {
+ VStack {
+ pillButton
+ Spacer()
+ }
+ .popover(isPresented: $showPopover, arrowEdge: .bottom) {
+ UpdatePopoverView(model: model, actions: actions)
+ }
+ .transition(.opacity.combined(with: .scale(scale: 0.95)))
+ }
+ }
+
+ /// The pill-shaped button view that displays the update badge and text
+ @ViewBuilder
+ private var pillButton: some View {
+ Button(action: { showPopover.toggle() }) {
+ HStack(spacing: 6) {
+ UpdateBadge(model: model)
+ .frame(width: 14, height: 14)
+
+ Text(model.text)
+ .font(.system(size: 11, weight: .medium))
+ .lineLimit(1)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(
+ Capsule()
+ .fill(model.backgroundColor)
+ )
+ .foregroundColor(model.foregroundColor)
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .help(model.stateTooltip)
+ }
+}
diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift
new file mode 100644
index 000000000..af870b4de
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdatePopoverView.swift
@@ -0,0 +1,362 @@
+import SwiftUI
+
+/// A popover view that displays detailed update information and action buttons.
+///
+/// The view adapts its content based on the current update state, showing appropriate
+/// UI for checking, downloading, installing, or handling errors.
+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
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ switch model.state {
+ case .idle:
+ EmptyView()
+
+ case .permissionRequest:
+ permissionRequestView
+
+ case .checking:
+ checkingView
+
+ case .updateAvailable:
+ updateAvailableView
+
+ case .downloading:
+ downloadingView
+
+ case .extracting:
+ extractingView
+
+ case .readyToInstall:
+ readyToInstallView
+
+ case .installing:
+ installingView
+
+ case .notFound:
+ notFoundView
+
+ case .error:
+ errorView
+ }
+ }
+ .frame(width: 300)
+ }
+
+ /// View shown when requesting permission to enable automatic updates
+ private var permissionRequestView: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Enable automatic updates?")
+ .font(.system(size: 13, weight: .semibold))
+
+ Text("Ghostty can automatically check for and download updates in the background.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ HStack(spacing: 8) {
+ Button("Not Now") {
+ actions.denyAutoChecks()
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+
+ Spacer()
+
+ Button("Allow") {
+ actions.allowAutoChecks()
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ .padding(16)
+ }
+
+ /// View shown while checking for updates
+ private var checkingView: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(spacing: 10) {
+ ProgressView()
+ .controlSize(.small)
+ Text("Checking for updates…")
+ .font(.system(size: 13))
+ }
+
+ HStack {
+ Spacer()
+ Button("Cancel") {
+ actions.cancel()
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+
+ /// View shown when an update is available, displaying version and size information
+ private var updateAvailableView: 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))
+ }
+ }
+ }
+ }
+
+ HStack(spacing: 8) {
+ Button("Skip") {
+ actions.skipThisVersion()
+ dismiss()
+ }
+ .controlSize(.small)
+
+ Button("Later") {
+ actions.remindLater()
+ dismiss()
+ }
+ .controlSize(.small)
+ .keyboardShortcut(.cancelAction)
+
+ Spacer()
+
+ Button("Install") {
+ actions.install()
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+ }
+ .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))
+ }
+ }
+ }
+
+ /// View shown while downloading an update, with progress indicator
+ private var downloadingView: 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 {
+ VStack(alignment: .leading, spacing: 6) {
+ ProgressView(value: progress)
+ Text(String(format: "%.0f%%", progress * 100))
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+ } else {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ HStack {
+ Spacer()
+ Button("Cancel") {
+ actions.cancel()
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+
+ /// View shown while extracting/preparing the downloaded update
+ private var extractingView: 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)
+ }
+ }
+ .padding(16)
+ }
+
+ /// View shown when an update is ready to be installed
+ private var readyToInstallView: 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)
+ }
+ }
+
+ HStack(spacing: 8) {
+ Button("Later") {
+ actions.remindLater()
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .controlSize(.small)
+
+ Spacer()
+
+ Button("Install and Relaunch") {
+ actions.install()
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+
+ /// View shown during the installation process
+ private var installingView: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 10) {
+ ProgressView()
+ .controlSize(.small)
+ Text("Installing…")
+ .font(.system(size: 13, weight: .semibold))
+ }
+
+ Text("The application will relaunch shortly.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+ .padding(16)
+ }
+
+ /// View shown when no updates are found (already on latest version)
+ private var notFoundView: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("No Updates Found")
+ .font(.system(size: 13, weight: .semibold))
+
+ Text("You're already running the latest version.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ HStack {
+ Spacer()
+ Button("OK") {
+ actions.remindLater()
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+
+ /// View shown when an error occurs during the update process
+ private var errorView: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 8) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.orange)
+ .font(.system(size: 13))
+ Text(model.error?.title ?? "Update Failed")
+ .font(.system(size: 13, weight: .semibold))
+ }
+
+ if let message = model.error?.message {
+ Text(message)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ HStack(spacing: 8) {
+ Button("OK") {
+ actions.remindLater()
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .controlSize(.small)
+
+ Spacer()
+
+ Button("Retry") {
+ actions.retry()
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+}
diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift
new file mode 100644
index 000000000..fb477324c
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdateViewModel.swift
@@ -0,0 +1,178 @@
+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
+}
+
+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"
+ }
+ }
+
+ var text: String {
+ switch state {
+ case .idle:
+ return ""
+ case .permissionRequest:
+ 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 {
+ 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 "Install Update"
+ case .installing:
+ return "Installing…"
+ case .notFound:
+ return "No Updates Available"
+ case .error:
+ return error?.title ?? "Update Failed"
+ }
+ }
+
+ var iconName: String {
+ switch state {
+ case .idle:
+ return ""
+ case .permissionRequest:
+ return "questionmark.circle"
+ case .checking:
+ return "arrow.triangle.2.circlepath"
+ case .updateAvailable:
+ return "arrow.down.circle.fill"
+ case .downloading, .extracting:
+ return "" // Progress ring instead
+ case .readyToInstall:
+ return "checkmark.circle.fill"
+ case .installing:
+ return "gear"
+ case .notFound:
+ return "info.circle"
+ case .error:
+ return "exclamationmark.triangle.fill"
+ }
+ }
+
+ var iconColor: Color {
+ switch state {
+ case .idle:
+ return .secondary
+ case .permissionRequest, .checking:
+ return .secondary
+ case .updateAvailable, .readyToInstall:
+ return .accentColor
+ case .downloading, .extracting, .installing:
+ return .secondary
+ case .notFound:
+ return .secondary
+ case .error:
+ return .orange
+ }
+ }
+
+ var backgroundColor: Color {
+ switch state {
+ case .updateAvailable:
+ return .accentColor
+ case .readyToInstall:
+ return Color(nsColor: NSColor.systemGreen.blended(withFraction: 0.3, of: .black) ?? .systemGreen)
+ case .error:
+ return .orange.opacity(0.2)
+ default:
+ return Color(nsColor: .controlBackgroundColor)
+ }
+ }
+
+ var foregroundColor: Color {
+ switch state {
+ case .updateAvailable, .readyToInstall:
+ return .white
+ case .error:
+ return .orange
+ default:
+ return .primary
+ }
+ }
+}