From 09ba5a27a234bfe5c8cad1c51da7b5028f239f1b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 08:43:39 -0700 Subject: macOS: Unobtrusive update views --- macos/Sources/App/macOS/AppDelegate.swift | 30 +- .../Terminal/Window Styles/TerminalWindow.swift | 140 +++++++- macos/Sources/Features/Update/UpdateBadge.swift | 65 ++++ macos/Sources/Features/Update/UpdatePill.swift | 51 +++ .../Features/Update/UpdatePopoverView.swift | 362 +++++++++++++++++++++ .../Sources/Features/Update/UpdateViewModel.swift | 178 ++++++++++ 6 files changed, 814 insertions(+), 12 deletions(-) create mode 100644 macos/Sources/Features/Update/UpdateBadge.swift create mode 100644 macos/Sources/Features/Update/UpdatePill.swift create mode 100644 macos/Sources/Features/Update/UpdatePopoverView.swift create mode 100644 macos/Sources/Features/Update/UpdateViewModel.swift 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 + } + } +} -- cgit v1.2.3 From fc347a6040b5e626f630c19afce69f947318e4f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 12:40:42 -0700 Subject: macOS: Move update view model over to App scope --- macos/Sources/App/macOS/AppDelegate.swift | 23 +++---- .../Terminal/Window Styles/TerminalWindow.swift | 72 ++++++++++++---------- 2 files changed, 50 insertions(+), 45 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index a893c3877..898191b1a 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -101,6 +101,9 @@ class AppDelegate: NSObject, /// Manages updates let updaterController: SPUStandardUpdaterController let updaterDelegate: UpdaterDelegate = UpdaterDelegate() + + /// Update view model for UI display + @Published private(set) var updateUIModel = UpdateViewModel() /// The elapsed time since the process was started var timeSinceLaunch: TimeInterval { @@ -1008,24 +1011,16 @@ class AppDelegate: NSObject, // 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 + updateUIModel.state = .checking + updateUIModel.progress = nil + updateUIModel.details = nil + updateUIModel.error = nil DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { // Simulate finding an update - model.state = .updateAvailable - model.details = .init( + self.updateUIModel.state = .updateAvailable + self.updateUIModel.details = .init( version: "1.2.0", build: "demo", size: "42 MB", diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 248577f4f..87f2be1ca 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -17,7 +17,6 @@ class TerminalWindow: NSWindow { /// 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() @@ -94,7 +93,7 @@ class TerminalWindow: NSWindow { updateAccessory.layoutAttribute = .right updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( viewModel: viewModel, - model: updateUIModel, + model: appDelegate.updateUIModel, actions: createUpdateActions() )) addTitlebarAccessoryViewController(updateAccessory) @@ -457,48 +456,60 @@ class TerminalWindow: NSWindow { // MARK: Update UI private func createUpdateActions() -> UpdateUIActions { - UpdateUIActions( - allowAutoChecks: { [weak self] in + guard let appDelegate = NSApp.delegate as? AppDelegate else { + return UpdateUIActions( + allowAutoChecks: {}, + denyAutoChecks: {}, + cancel: {}, + install: {}, + remindLater: {}, + skipThisVersion: {}, + showReleaseNotes: {}, + retry: {} + ) + } + + return UpdateUIActions( + allowAutoChecks: { print("Demo: Allow auto checks") - self?.updateUIModel.state = .idle + appDelegate.updateUIModel.state = .idle }, - denyAutoChecks: { [weak self] in + denyAutoChecks: { print("Demo: Deny auto checks") - self?.updateUIModel.state = .idle + appDelegate.updateUIModel.state = .idle }, - cancel: { [weak self] in + cancel: { print("Demo: Cancel") - self?.updateUIModel.state = .idle + appDelegate.updateUIModel.state = .idle }, - install: { [weak self] in - guard let self else { return } + install: { print("Demo: Install - simulating download and install flow") // Start downloading - self.updateUIModel.state = .downloading - self.updateUIModel.progress = 0.0 + appDelegate.updateUIModel.state = .downloading + appDelegate.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 + appDelegate.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 + appDelegate.updateUIModel.state = .extracting + appDelegate.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 + appDelegate.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 + appDelegate.updateUIModel.state = .readyToInstall + appDelegate.updateUIModel.progress = nil } } } @@ -508,29 +519,28 @@ class TerminalWindow: NSWindow { } } }, - remindLater: { [weak self] in + remindLater: { print("Demo: Remind later") - self?.updateUIModel.state = .idle + appDelegate.updateUIModel.state = .idle }, - skipThisVersion: { [weak self] in + skipThisVersion: { print("Demo: Skip version") - self?.updateUIModel.state = .idle + appDelegate.updateUIModel.state = .idle }, - showReleaseNotes: { [weak self] in + 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: { [weak self] in - guard let self else { return } + retry: { print("Demo: Retry - simulating update check") - self.updateUIModel.state = .checking - self.updateUIModel.progress = nil - self.updateUIModel.error = nil + appDelegate.updateUIModel.state = .checking + appDelegate.updateUIModel.progress = nil + appDelegate.updateUIModel.error = nil DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.updateUIModel.state = .updateAvailable - self.updateUIModel.details = .init( + appDelegate.updateUIModel.state = .updateAvailable + appDelegate.updateUIModel.details = .init( version: "1.2.0", build: "demo", size: "42 MB", -- cgit v1.2.3 From 81e3ff90a35ed75839cea83d909790bf7d807c63 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 13:24:37 -0700 Subject: macOS: Show update information as an overlay --- macos/Sources/App/macOS/AppDelegate.swift | 84 +++++++++++++++++ macos/Sources/Features/Terminal/TerminalView.swift | 20 ++++ .../Terminal/Window Styles/TerminalWindow.swift | 101 +-------------------- macos/Sources/Features/Update/UpdatePill.swift | 13 +-- 4 files changed, 110 insertions(+), 108 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 898191b1a..cfa871b32 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -104,6 +104,11 @@ class AppDelegate: NSObject, /// Update view model for UI display @Published private(set) var updateUIModel = UpdateViewModel() + + /// Update actions for UI interactions + private(set) lazy var updateActions: UpdateUIActions = { + createUpdateActions() + }() /// The elapsed time since the process was started var timeSinceLaunch: TimeInterval { @@ -1029,6 +1034,85 @@ class AppDelegate: NSObject, ) } } + + 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 { + 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 + } + } + } + } + } + } + } + } + }, + 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 b5be0ae42..54d2011c7 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -109,6 +109,26 @@ struct TerminalView: View { self.delegate?.performAction(action, on: surfaceView) } } + + // Show update information above all else. + UpdateOverlay() + } + } + } +} + +fileprivate struct UpdateOverlay: View { + var body: some View { + if let appDelegate = NSApp.delegate as? AppDelegate { + VStack { + Spacer() + + HStack { + Spacer() + UpdatePill(model: appDelegate.updateUIModel, actions: appDelegate.updateActions) + .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 87f2be1ca..62439f676 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -94,7 +94,7 @@ class TerminalWindow: NSWindow { updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( viewModel: viewModel, model: appDelegate.updateUIModel, - actions: createUpdateActions() + actions: appDelegate.updateActions )) addTitlebarAccessoryViewController(updateAccessory) updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false @@ -453,105 +453,6 @@ class TerminalWindow: NSWindow { standardWindowButton(.zoomButton)?.isHidden = true } - // MARK: Update UI - - private func createUpdateActions() -> UpdateUIActions { - guard let appDelegate = NSApp.delegate as? AppDelegate else { - return UpdateUIActions( - allowAutoChecks: {}, - denyAutoChecks: {}, - cancel: {}, - install: {}, - remindLater: {}, - skipThisVersion: {}, - showReleaseNotes: {}, - retry: {} - ) - } - - return UpdateUIActions( - allowAutoChecks: { - print("Demo: Allow auto checks") - appDelegate.updateUIModel.state = .idle - }, - denyAutoChecks: { - print("Demo: Deny auto checks") - appDelegate.updateUIModel.state = .idle - }, - cancel: { - print("Demo: Cancel") - appDelegate.updateUIModel.state = .idle - }, - install: { - print("Demo: Install - simulating download and install flow") - - // Start downloading - appDelegate.updateUIModel.state = .downloading - appDelegate.updateUIModel.progress = 0.0 - - // Simulate download progress - for i in 1...10 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { - appDelegate.updateUIModel.progress = Double(i) / 10.0 - - if i == 10 { - // Move to extraction - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - appDelegate.updateUIModel.state = .extracting - appDelegate.updateUIModel.progress = 0.0 - - // Simulate extraction progress - for j in 1...5 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { - appDelegate.updateUIModel.progress = Double(j) / 5.0 - - if j == 5 { - // Move to ready to install - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - appDelegate.updateUIModel.state = .readyToInstall - appDelegate.updateUIModel.progress = nil - } - } - } - } - } - } - } - } - }, - remindLater: { - print("Demo: Remind later") - appDelegate.updateUIModel.state = .idle - }, - skipThisVersion: { - print("Demo: Skip version") - appDelegate.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") - appDelegate.updateUIModel.state = .checking - appDelegate.updateUIModel.progress = nil - appDelegate.updateUIModel.error = nil - - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - appDelegate.updateUIModel.state = .updateAvailable - appDelegate.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 struct DerivedConfig { diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index 604be0fbc..ea3c093dc 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -13,14 +13,11 @@ struct UpdatePill: View { 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))) + pillButton + .popover(isPresented: $showPopover, arrowEdge: .bottom) { + UpdatePopoverView(model: model, actions: actions) + } + .transition(.opacity.combined(with: .scale(scale: 0.95))) } } -- cgit v1.2.3 From f975ac8019c2e5854dbc4b8f6fe1f1913b319a72 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 13:54:58 -0700 Subject: macOS: only show the update overlay if window doesn't support it --- .../QuickTerminal/QuickTerminalController.swift | 2 +- .../Features/Terminal/BaseTerminalController.swift | 38 +++++++++++++++++++++- macos/Sources/Features/Terminal/TerminalView.swift | 7 +++- .../HiddenTitlebarTerminalWindow.swift | 3 ++ .../Terminal/Window Styles/TerminalWindow.swift | 27 ++++++++++----- .../TitlebarTabsTahoeTerminalWindow.swift | 4 +++ .../TitlebarTabsVenturaTerminalWindow.swift | 4 +++ 7 files changed, 73 insertions(+), 12 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index fcc8c6505..37c9985c9 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -37,7 +37,7 @@ class QuickTerminalController: BaseTerminalController { /// Tracks if we're currently handling a manual resize to prevent recursion private var isHandlingResize: Bool = false - + init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index f660ea3ad..b9f9c5a05 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -48,6 +48,9 @@ class BaseTerminalController: NSWindowController, /// This can be set to show/hide the command palette. @Published var commandPaletteIsShowing: Bool = false + + /// Set if the terminal view should show the update overlay. + @Published var updateOverlayIsVisible: Bool = false /// Whether the terminal surface should focus when the mouse is over it. var focusFollowsMouse: Bool { @@ -818,7 +821,18 @@ class BaseTerminalController: NSWindowController, } } - func fullscreenDidChange() {} + func fullscreenDidChange() { + guard let fullscreenStyle else { return } + + // When we enter fullscreen, we want to show the update overlay so that it + // is easily visible. For native fullscreen this is visible by showing the + // menubar but we don't want to rely on that. + if fullscreenStyle.isFullscreen { + updateOverlayIsVisible = true + } else { + updateOverlayIsVisible = defaultUpdateOverlayVisibility() + } + } // MARK: Clipboard Confirmation @@ -900,6 +914,28 @@ class BaseTerminalController: NSWindowController, fullscreenStyle = NativeFullscreen(window) fullscreenStyle?.delegate = self } + + // Set our update overlay state + updateOverlayIsVisible = defaultUpdateOverlayVisibility() + } + + func defaultUpdateOverlayVisibility() -> Bool { + guard let window else { return true } + + // No titlebar we always show the update overlay because it can't support + // updates in the titlebar + guard window.styleMask.contains(.titled) else { + return true + } + + // If it's a non terminal window we can't trust it has an update accessory, + // so we always want to show the overlay. + guard let window = window as? TerminalWindow else { + return true + } + + // Show the overlay if the window isn't. + return !window.supportsUpdateAccessory } // MARK: NSWindowDelegate diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 54d2011c7..832cd7966 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -31,6 +31,9 @@ protocol TerminalViewModel: ObservableObject { /// The command palette state. var commandPaletteIsShowing: Bool { get set } + + /// The update overlay should be visible. + var updateOverlayIsVisible: Bool { get } } /// The main terminal view. This terminal view supports splits. @@ -111,7 +114,9 @@ struct TerminalView: View { } // Show update information above all else. - UpdateOverlay() + if viewModel.updateOverlayIsVisible { + UpdateOverlay() + } } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift index dc7dd7633..dd8b258f3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift @@ -1,6 +1,9 @@ import AppKit class HiddenTitlebarTerminalWindow: TerminalWindow { + // No titlebar, we don't support accessories. + override var supportsUpdateAccessory: Bool { false } + override func awakeFromNib() { super.awakeFromNib() diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 62439f676..6e657f33e 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -20,12 +20,19 @@ class TerminalWindow: NSWindow { /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() + + /// Whether this window supports the update accessory. If this is false, then views within this + /// window should determine how to show update notifications. + var supportsUpdateAccessory: Bool { + // Native window supports it. + true + } /// Gets the terminal controller from the window controller. var terminalController: TerminalController? { windowController as? TerminalController } - + // MARK: NSWindow Overrides override var toolbar: NSToolbar? { @@ -90,14 +97,16 @@ class TerminalWindow: NSWindow { resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false // Create update notification accessory - updateAccessory.layoutAttribute = .right - updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( - viewModel: viewModel, - model: appDelegate.updateUIModel, - actions: appDelegate.updateActions - )) - addTitlebarAccessoryViewController(updateAccessory) - updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false + if supportsUpdateAccessory { + updateAccessory.layoutAttribute = .right + updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( + viewModel: viewModel, + model: appDelegate.updateUIModel, + actions: appDelegate.updateActions + )) + addTitlebarAccessoryViewController(updateAccessory) + updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false + } } // Setup the accessory view for tabs that shows our keyboard shortcuts, diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 260fac4cc..855d29f52 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -8,6 +8,10 @@ import SwiftUI class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { /// The view model for SwiftUI views private var viewModel = ViewModel() + + /// Titlebar tabs can't support the update accessory because of the way we layout + /// the native tabs back into the menu bar. + override var supportsUpdateAccessory: Bool { false } deinit { tabBarObserver = nil diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 8589877d8..0c087faeb 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -2,6 +2,10 @@ import Cocoa /// Titlebar tabs for macOS 13 to 15. class TitlebarTabsVenturaTerminalWindow: TerminalWindow { + /// Titlebar tabs can't support the update accessory because of the way we layout + /// the native tabs back into the menu bar. + override var supportsUpdateAccessory: Bool { false } + /// This is used to determine if certain elements should be drawn light or dark and should /// be updated whenever the window background color or surrounding elements changes. fileprivate var isLightTheme: Bool = false -- cgit v1.2.3 From 59829f53598b48c73137fbfb1d0c7e81375569ac Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 15:52:42 -0700 Subject: Sparkle user driver, drives updates to the view model. --- macos/Sources/App/macOS/AppDelegate.swift | 156 ++++++--------- macos/Sources/Features/Terminal/TerminalView.swift | 2 +- .../Terminal/Window Styles/TerminalWindow.swift | 6 +- macos/Sources/Features/Update/UpdateBadge.swift | 32 +-- macos/Sources/Features/Update/UpdateDriver.swift | 103 ++++++++++ macos/Sources/Features/Update/UpdatePill.swift | 9 +- .../Features/Update/UpdatePopoverView.swift | 216 ++++++++++----------- .../Sources/Features/Update/UpdateViewModel.swift | 165 +++++++--------- 8 files changed, 371 insertions(+), 318 deletions(-) create mode 100644 macos/Sources/Features/Update/UpdateDriver.swift 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 + } +} -- cgit v1.2.3 From a55de09944865044d2f44a37a177bf0d5f882cdb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:09:06 -0700 Subject: macos: update simulator to test various scenarios in UI --- macos/Sources/App/macOS/AppDelegate.swift | 76 +----- .../Sources/Features/Update/UpdateSimulator.swift | 272 +++++++++++++++++++++ 2 files changed, 273 insertions(+), 75 deletions(-) create mode 100644 macos/Sources/Features/Update/UpdateSimulator.swift diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 4fd6dfb3f..a9f72b58b 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1008,82 +1008,8 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - // 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) { [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 simulateDownload() { - let download = UpdateState.Downloading( - cancel: { [weak self] in - self?.updateViewModel.state = .idle - }, - 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) - - 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)) - - 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 - } - } - )) - } - } - } - } + UpdateSimulator.notFound.simulate(with: updateViewModel) } - @IBAction func newWindow(_ sender: Any?) { diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift new file mode 100644 index 000000000..0cf2d221b --- /dev/null +++ b/macos/Sources/Features/Update/UpdateSimulator.swift @@ -0,0 +1,272 @@ +import Foundation +import Sparkle + +/// Simulates various update scenarios for testing the update UI. +/// +/// The expected usage is by overriding the `checkForUpdates` function in AppDelegate and +/// calling one of these instead. This will allow us to test the update flows without having to use +/// real updates. +enum UpdateSimulator { + /// Complete successful update flow: checking → available → download → extract → ready → install → idle + case happyPath + + /// No updates available: checking (2s) → "No Updates Available" (3s) → idle + case notFound + + /// Error during check: checking (2s) → error with retry callback + case error + + /// Slower download for testing progress UI: checking → available → download (20 steps, ~10s) → extract → install + case slowDownload + + /// Initial permission request flow: shows permission dialog → proceeds with happy path if accepted + case permissionRequest + + /// User cancels during download: checking → available → download (5 steps) → cancels → idle + case cancelDuringDownload + + /// User cancels while checking: checking (1s) → cancels → idle + case cancelDuringChecking + + func simulate(with viewModel: UpdateViewModel) { + switch self { + case .happyPath: + simulateHappyPath(viewModel) + case .notFound: + simulateNotFound(viewModel) + case .error: + simulateError(viewModel) + case .slowDownload: + simulateSlowDownload(viewModel) + case .permissionRequest: + simulatePermissionRequest(viewModel) + case .cancelDuringDownload: + simulateCancelDuringDownload(viewModel) + case .cancelDuringChecking: + simulateCancelDuringChecking(viewModel) + } + } + + private func simulateHappyPath(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .updateAvailable(.init( + appcastItem: SUAppcastItem.empty(), + reply: { choice in + if choice == .install { + simulateDownload(viewModel) + } else { + viewModel.state = .idle + } + } + )) + } + } + + private func simulateNotFound(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .notFound + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + viewModel.state = .idle + } + } + } + + private func simulateError(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .error(.init( + error: NSError(domain: "UpdateError", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to check for updates" + ]), + retry: { + simulateHappyPath(viewModel) + } + )) + } + } + + private func simulateSlowDownload(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .updateAvailable(.init( + appcastItem: SUAppcastItem.empty(), + reply: { choice in + if choice == .install { + simulateSlowDownloadProgress(viewModel) + } else { + viewModel.state = .idle + } + } + )) + } + } + + private func simulateSlowDownloadProgress(_ viewModel: UpdateViewModel) { + let download = UpdateState.Downloading( + cancel: { + viewModel.state = .idle + }, + expectedLength: nil, + progress: 0 + ) + viewModel.state = .downloading(download) + + for i in 1...20 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.5) { + let updatedDownload = UpdateState.Downloading( + cancel: download.cancel, + expectedLength: 2000, + progress: UInt64(i * 100) + ) + viewModel.state = .downloading(updatedDownload) + + if i == 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + simulateExtract(viewModel) + } + } + } + } + } + + private func simulatePermissionRequest(_ viewModel: UpdateViewModel) { + let request = SPUUpdatePermissionRequest(systemProfile: []) + viewModel.state = .permissionRequest(.init( + request: request, + reply: { response in + if response.automaticUpdateChecks { + simulateHappyPath(viewModel) + } else { + viewModel.state = .idle + } + } + )) + } + + private func simulateCancelDuringDownload(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .updateAvailable(.init( + appcastItem: SUAppcastItem.empty(), + reply: { choice in + if choice == .install { + simulateDownloadThenCancel(viewModel) + } else { + viewModel.state = .idle + } + } + )) + } + } + + private func simulateDownloadThenCancel(_ viewModel: UpdateViewModel) { + let download = UpdateState.Downloading( + cancel: { + viewModel.state = .idle + }, + expectedLength: nil, + progress: 0 + ) + viewModel.state = .downloading(download) + + for i in 1...5 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { + let updatedDownload = UpdateState.Downloading( + cancel: download.cancel, + expectedLength: 1000, + progress: UInt64(i * 100) + ) + viewModel.state = .downloading(updatedDownload) + + if i == 5 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + viewModel.state = .idle + } + } + } + } + } + + private func simulateCancelDuringChecking(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + viewModel.state = .idle + } + } + + private func simulateDownload(_ viewModel: UpdateViewModel) { + let download = UpdateState.Downloading( + cancel: { + viewModel.state = .idle + }, + expectedLength: nil, + progress: 0 + ) + viewModel.state = .downloading(download) + + for i in 1...10 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { + let updatedDownload = UpdateState.Downloading( + cancel: download.cancel, + expectedLength: 1000, + progress: UInt64(i * 100) + ) + viewModel.state = .downloading(updatedDownload) + + if i == 10 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + simulateExtract(viewModel) + } + } + } + } + } + + private func simulateExtract(_ viewModel: UpdateViewModel) { + viewModel.state = .extracting(.init(progress: 0.0)) + + for j in 1...5 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { + viewModel.state = .extracting(.init(progress: Double(j) / 5.0)) + + if j == 5 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + viewModel.state = .readyToInstall(.init( + reply: { choice in + if choice == .install { + viewModel.state = .installing + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .idle + } + } else { + viewModel.state = .idle + } + } + )) + } + } + } + } + } +} -- cgit v1.2.3 From 95a9e6340134cafc084fe560b8f2c94e8e7baac6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:13:34 -0700 Subject: macos: not found state dismisses on click, after 5s --- macos/Sources/Features/Update/UpdatePill.swift | 18 +++++++++++- .../Sources/Features/Update/UpdateViewModel.swift | 33 +++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index dda9ad607..1dc29e250 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -15,13 +15,29 @@ struct UpdatePill: View { UpdatePopoverView(model: model) } .transition(.opacity.combined(with: .scale(scale: 0.95))) + .onChange(of: model.state) { newState in + if case .notFound = newState { + Task { + try? await Task.sleep(for: .seconds(5)) + if case .notFound = model.state { + model.state = .idle + } + } + } + } } } /// The pill-shaped button view that displays the update badge and text @ViewBuilder private var pillButton: some View { - Button(action: { showPopover.toggle() }) { + Button(action: { + if case .notFound = model.state { + model.state = .idle + } else { + showPopover.toggle() + } + }) { HStack(spacing: 6) { UpdateBadge(model: model) .frame(width: 14, height: 14) diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 57e438bcd..05f7eef9a 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -87,6 +87,8 @@ class UpdateViewModel: ObservableObject { return .accentColor case .readyToInstall: return Color(nsColor: NSColor.systemGreen.blended(withFraction: 0.3, of: .black) ?? .systemGreen) + case .notFound: + return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue) case .error: return .orange.opacity(0.2) default: @@ -99,6 +101,8 @@ class UpdateViewModel: ObservableObject { switch state { case .updateAvailable, .readyToInstall: return .white + case .notFound: + return .white case .error: return .orange default: @@ -107,7 +111,7 @@ class UpdateViewModel: ObservableObject { } } -enum UpdateState { +enum UpdateState: Equatable { case idle case permissionRequest(PermissionRequest) case checking(Checking) @@ -124,6 +128,33 @@ enum UpdateState { return false } + static func == (lhs: UpdateState, rhs: UpdateState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle): + return true + case (.permissionRequest, .permissionRequest): + return true + case (.checking, .checking): + return true + case (.updateAvailable(let lUpdate), .updateAvailable(let rUpdate)): + return lUpdate.appcastItem.displayVersionString == rUpdate.appcastItem.displayVersionString + case (.notFound, .notFound): + return true + case (.error(let lErr), .error(let rErr)): + return lErr.error.localizedDescription == rErr.error.localizedDescription + case (.downloading(let lDown), .downloading(let rDown)): + return lDown.progress == rDown.progress && lDown.expectedLength == rDown.expectedLength + case (.extracting(let lExt), .extracting(let rExt)): + return lExt.progress == rExt.progress + case (.readyToInstall, .readyToInstall): + return true + case (.installing, .installing): + return true + default: + return false + } + } + struct PermissionRequest { let request: SPUUpdatePermissionRequest let reply: @Sendable (SUUpdatePermissionResponse) -> Void -- cgit v1.2.3 From 9e17255ca9f097ebcccb0025ce32c798360f31a0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:16:07 -0700 Subject: macos: "OK" should dismiss error --- macos/Sources/App/macOS/AppDelegate.swift | 2 +- macos/Sources/Features/Update/UpdateDriver.swift | 4 +++- macos/Sources/Features/Update/UpdatePopoverView.swift | 1 + macos/Sources/Features/Update/UpdateSimulator.swift | 3 +++ macos/Sources/Features/Update/UpdateViewModel.swift | 1 + 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index a9f72b58b..9e1425062 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1008,7 +1008,7 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - UpdateSimulator.notFound.simulate(with: updateViewModel) + UpdateSimulator.error.simulate(with: updateViewModel) } diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 00f74e9ed..6627559e8 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -38,7 +38,9 @@ class UpdateDriver: NSObject, SPUUserDriver { } func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) { - viewModel.state = .error(.init(error: error, retry: retryHandler)) + viewModel.state = .error(.init(error: error, retry: retryHandler, dismiss: { [weak viewModel] in + viewModel?.state = .idle + })) } func showDownloadInitiated(cancellation: @escaping () -> Void) { diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 39c4ac5c9..cbe517f74 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -336,6 +336,7 @@ fileprivate struct UpdateErrorView: View { HStack(spacing: 8) { Button("OK") { + error.dismiss() dismiss() } .keyboardShortcut(.cancelAction) diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift index 0cf2d221b..96fab4835 100644 --- a/macos/Sources/Features/Update/UpdateSimulator.swift +++ b/macos/Sources/Features/Update/UpdateSimulator.swift @@ -92,6 +92,9 @@ enum UpdateSimulator { ]), retry: { simulateHappyPath(viewModel) + }, + dismiss: { + viewModel.state = .idle } )) } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 05f7eef9a..f0b779d60 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -172,6 +172,7 @@ enum UpdateState: Equatable { struct Error { let error: any Swift.Error let retry: () -> Void + let dismiss: () -> Void } struct Downloading { -- cgit v1.2.3 From b4ab1cc1edd273577449c9ced8f713c4293134b7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:21:26 -0700 Subject: macos: clean up the permission request --- macos/Sources/App/macOS/AppDelegate.swift | 2 +- macos/Sources/Features/Update/UpdatePopoverView.swift | 2 +- macos/Sources/Features/Update/UpdateViewModel.swift | 10 ++++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 9e1425062..62c5c0316 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1008,7 +1008,7 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - UpdateSimulator.error.simulate(with: updateViewModel) + UpdateSimulator.permissionRequest.simulate(with: updateViewModel) } diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index cbe517f74..7f1886d60 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -62,7 +62,7 @@ fileprivate struct PermissionRequestView: View { Text("Enable automatic updates?") .font(.system(size: 13, weight: .semibold)) - Text("Ghostty can automatically check for and download updates in the background.") + Text("Ghostty can automatically check for updates in the background.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index f0b779d60..674888bb5 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -13,7 +13,7 @@ class UpdateViewModel: ObservableObject { case .idle: return "" case .permissionRequest: - return "Update Permission" + return "Enable Automatic Updates?" case .checking: return "Checking for Updates…" case .updateAvailable(let update): @@ -67,7 +67,9 @@ class UpdateViewModel: ObservableObject { switch state { case .idle: return .secondary - case .permissionRequest, .checking: + case .permissionRequest: + return .white + case .checking: return .secondary case .updateAvailable, .readyToInstall: return .accentColor @@ -83,6 +85,8 @@ class UpdateViewModel: ObservableObject { /// The background color for the update pill. var backgroundColor: Color { switch state { + case .permissionRequest: + return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue) case .updateAvailable: return .accentColor case .readyToInstall: @@ -99,6 +103,8 @@ class UpdateViewModel: ObservableObject { /// The foreground (text) color for the update pill. var foregroundColor: Color { switch state { + case .permissionRequest: + return .white case .updateAvailable, .readyToInstall: return .white case .notFound: -- cgit v1.2.3 From bce49a08438b39bbc699348d8308e724f6334f75 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:29:14 -0700 Subject: macos: hook up our new update controller --- macos/Sources/App/macOS/AppDelegate.swift | 31 +++++------- .../Sources/Features/Update/UpdateController.swift | 55 ++++++++++++++++++++++ macos/Sources/Features/Update/UpdateDriver.swift | 20 +++++--- 3 files changed, 80 insertions(+), 26 deletions(-) create mode 100644 macos/Sources/Features/Update/UpdateController.swift diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 62c5c0316..216373e7e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -99,11 +99,10 @@ class AppDelegate: NSObject, ) /// Manages updates - let updaterController: SPUStandardUpdaterController - let updaterDelegate: UpdaterDelegate = UpdaterDelegate() - - /// Update view model for UI display - @Published private(set) var updateViewModel = UpdateViewModel() + let updateController = UpdateController() + var updateViewModel: UpdateViewModel { + updateController.viewModel + } /// The elapsed time since the process was started var timeSinceLaunch: TimeInterval { @@ -130,15 +129,6 @@ class AppDelegate: NSObject, } override init() { - updaterController = SPUStandardUpdaterController( - // Important: we must not start the updater here because we need to read our configuration - // first to determine whether we're automatically checking, downloading, etc. The updater - // is started later in applicationDidFinishLaunching - startingUpdater: false, - updaterDelegate: updaterDelegate, - userDriverDelegate: nil - ) - super.init() ghostty.delegate = self @@ -183,7 +173,7 @@ class AppDelegate: NSObject, ghosttyConfigDidChange(config: ghostty.config) // Start our update checker. - updaterController.startUpdater() + updateController.startUpdater() // Register our service provider. This must happen after everything is initialized. NSApp.servicesProvider = ServiceProvider() @@ -810,12 +800,12 @@ class AppDelegate: NSObject, // defined by our "auto-update" configuration (if set) or fall back to Sparkle // user-based defaults. if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false { - updaterController.updater.automaticallyChecksForUpdates = false - updaterController.updater.automaticallyDownloadsUpdates = false + updateController.updater.automaticallyChecksForUpdates = false + updateController.updater.automaticallyDownloadsUpdates = false } else if let autoUpdate = config.autoUpdate { - updaterController.updater.automaticallyChecksForUpdates = + updateController.updater.automaticallyChecksForUpdates = autoUpdate == .check || autoUpdate == .download - updaterController.updater.automaticallyDownloadsUpdates = + updateController.updater.automaticallyDownloadsUpdates = autoUpdate == .download } @@ -1008,7 +998,8 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - UpdateSimulator.permissionRequest.simulate(with: updateViewModel) + updateController.checkForUpdates() + //UpdateSimulator.permissionRequest.simulate(with: updateViewModel) } diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift new file mode 100644 index 000000000..47e6c8def --- /dev/null +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -0,0 +1,55 @@ +import Sparkle +import Cocoa + +/// Standard controller for managing Sparkle updates in Ghostty. +/// +/// This controller wraps SPUStandardUpdaterController to provide a simpler interface +/// for managing updates with Ghostty's custom driver and delegate. It handles +/// initialization, starting the updater, and provides the check for updates action. +class UpdateController { + private(set) var updater: SPUUpdater + private let userDriver: UpdateDriver + private let updaterDelegate = UpdaterDelegate() + + var viewModel: UpdateViewModel { + userDriver.viewModel + } + + /// Initialize a new update controller. + init() { + let hostBundle = Bundle.main + self.userDriver = UpdateDriver(viewModel: .init()) + self.updater = SPUUpdater( + hostBundle: hostBundle, + applicationBundle: hostBundle, + userDriver: userDriver, + delegate: updaterDelegate + ) + } + + /// Start the updater. + /// + /// This must be called before the updater can check for updates. If starting fails, + /// an error alert will be shown after a short delay. + func startUpdater() { + try? updater.start() + } + + /// Check for updates. + /// + /// This is typically connected to a menu item action. + @objc func checkForUpdates() { + updater.checkForUpdates() + } + + /// Validate the check for updates menu item. + /// + /// - Parameter item: The menu item to validate + /// - Returns: Whether the menu item should be enabled + func validateMenuItem(_ item: NSMenuItem) -> Bool { + if item.action == #selector(checkForUpdates) { + return updater.canCheckForUpdates + } + return true + } +} diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 6627559e8..70f9341a6 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -1,13 +1,12 @@ +import Cocoa 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) { + init(viewModel: UpdateViewModel) { self.viewModel = viewModel - self.retryHandler = retryHandler super.init() } @@ -38,9 +37,18 @@ class UpdateDriver: NSObject, SPUUserDriver { } func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) { - viewModel.state = .error(.init(error: error, retry: retryHandler, dismiss: { [weak viewModel] in - viewModel?.state = .idle - })) + viewModel.state = .error(.init( + error: error, + retry: { + guard let delegate = NSApp.delegate as? AppDelegate else { + return + } + + // TODO fill this in + }, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + })) } func showDownloadInitiated(cancellation: @escaping () -> Void) { -- cgit v1.2.3 From abab6899f93acc461f84a08cd333a6c93069dab1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:45:46 -0700 Subject: macos: better update descriptions --- .../Features/Update/UpdatePopoverView.swift | 25 +++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 7f1886d60..ae1dc9c28 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -124,6 +124,8 @@ fileprivate struct UpdateAvailableView: View { let update: UpdateState.UpdateAvailable let dismiss: DismissAction + private let labelWidth: CGFloat = 60 + var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) { @@ -135,11 +137,32 @@ fileprivate struct UpdateAvailableView: View { HStack(spacing: 6) { Text("Version:") .foregroundColor(.secondary) - .frame(width: 50, alignment: .trailing) + .frame(width: labelWidth, alignment: .trailing) Text(update.appcastItem.displayVersionString) } .font(.system(size: 11)) + + if update.appcastItem.contentLength > 0 { + HStack(spacing: 6) { + Text("Size:") + .foregroundColor(.secondary) + .frame(width: labelWidth, alignment: .trailing) + Text(ByteCountFormatter.string(fromByteCount: Int64(update.appcastItem.contentLength), countStyle: .file)) + } + .font(.system(size: 11)) + } + + if let date = update.appcastItem.date { + HStack(spacing: 6) { + Text("Released:") + .foregroundColor(.secondary) + .frame(width: labelWidth, alignment: .trailing) + Text(date.formatted(date: .abbreviated, time: .omitted)) + } + .font(.system(size: 11)) + } } + .textSelection(.enabled) } HStack(spacing: 8) { -- cgit v1.2.3 From 49eb65df77c16baaf74e78fd99373266b15e5b98 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:51:21 -0700 Subject: macos: show release notes link --- .../Features/Update/UpdatePopoverView.swift | 22 ++++ .../Sources/Features/Update/UpdateViewModel.swift | 70 +++++++++++ macos/Tests/Update/ReleaseNotesTests.swift | 130 +++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 macos/Tests/Update/ReleaseNotesTests.swift diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index ae1dc9c28..a73116ca0 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -191,6 +191,28 @@ fileprivate struct UpdateAvailableView: View { } } .padding(16) + + if let notes = update.releaseNotes { + Divider() + + Link(destination: notes.url) { + HStack { + Image(systemName: "doc.text") + .font(.system(size: 11)) + Text(notes.label) + .font(.system(size: 11, weight: .medium)) + Spacer() + Image(systemName: "arrow.up.right") + .font(.system(size: 10)) + } + .foregroundColor(.primary) + .padding(12) + .frame(maxWidth: .infinity) + .background(Color(nsColor: .controlBackgroundColor)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } } } } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 674888bb5..7b6119771 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -173,6 +173,76 @@ enum UpdateState: Equatable { struct UpdateAvailable { let appcastItem: SUAppcastItem let reply: @Sendable (SPUUserUpdateChoice) -> Void + + var releaseNotes: ReleaseNotes? { + let currentCommit = Bundle.main.infoDictionary?["GhosttyCommit"] as? String + return ReleaseNotes(displayVersionString: appcastItem.displayVersionString, currentCommit: currentCommit) + } + } + + enum ReleaseNotes { + case commit(URL) + case compareTip(URL) + case tagged(URL) + + init?(displayVersionString: String, currentCommit: String?) { + let version = displayVersionString + + // Check for semantic version (x.y.z) + if let semver = Self.extractSemanticVersion(from: version) { + let slug = semver.replacingOccurrences(of: ".", with: "-") + if let url = URL(string: "https://ghostty.org/docs/install/release-notes/\(slug)") { + self = .tagged(url) + return + } + } + + // Fall back to git hash detection + guard let newHash = Self.extractGitHash(from: version) else { + return nil + } + + if let currentHash = currentCommit, !currentHash.isEmpty, + let url = URL(string: "https://github.com/ghostty-org/ghostty/compare/\(currentHash)...\(newHash)") { + self = .compareTip(url) + } else if let url = URL(string: "https://github.com/ghostty-org/ghostty/commit/\(newHash)") { + self = .commit(url) + } else { + return nil + } + } + + private static func extractSemanticVersion(from version: String) -> String? { + let pattern = #"^\d+\.\d+\.\d+$"# + if version.range(of: pattern, options: .regularExpression) != nil { + return version + } + return nil + } + + private static func extractGitHash(from version: String) -> String? { + let pattern = #"[0-9a-f]{7,40}"# + if let range = version.range(of: pattern, options: .regularExpression) { + return String(version[range]) + } + return nil + } + + var url: URL { + switch self { + case .commit(let url): return url + case .compareTip(let url): return url + case .tagged(let url): return url + } + } + + var label: String { + switch (self) { + case .commit: return "View GitHub Commit" + case .compareTip: return "Changes Since This Tip Release" + case .tagged: return "View Release Notes" + } + } } struct Error { diff --git a/macos/Tests/Update/ReleaseNotesTests.swift b/macos/Tests/Update/ReleaseNotesTests.swift new file mode 100644 index 000000000..b029fa6bc --- /dev/null +++ b/macos/Tests/Update/ReleaseNotesTests.swift @@ -0,0 +1,130 @@ +import Testing +import Foundation +@testable import Ghostty + +struct ReleaseNotesTests { + /// Test tagged release (semantic version) + @Test func testTaggedRelease() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "1.2.3", + currentCommit: nil + ) + + #expect(notes != nil) + if case .tagged(let url) = notes { + #expect(url.absoluteString == "https://ghostty.org/docs/install/release-notes/1-2-3") + #expect(notes?.label == "View Release Notes") + } else { + Issue.record("Expected tagged case") + } + } + + /// Test tip release comparison with current commit + @Test func testTipReleaseComparison() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "tip-abc1234", + currentCommit: "def5678" + ) + + #expect(notes != nil) + if case .compareTip(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234") + #expect(notes?.label == "Changes Since This Tip Release") + } else { + Issue.record("Expected compareTip case") + } + } + + /// Test tip release without current commit + @Test func testTipReleaseWithoutCurrentCommit() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "tip-abc1234", + currentCommit: nil + ) + + #expect(notes != nil) + if case .commit(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234") + #expect(notes?.label == "View GitHub Commit") + } else { + Issue.record("Expected commit case") + } + } + + /// Test tip release with empty current commit + @Test func testTipReleaseWithEmptyCurrentCommit() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "tip-abc1234", + currentCommit: "" + ) + + #expect(notes != nil) + if case .commit(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234") + } else { + Issue.record("Expected commit case") + } + } + + /// Test version with full 40-character hash + @Test func testFullGitHash() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "tip-1234567890abcdef1234567890abcdef12345678", + currentCommit: nil + ) + + #expect(notes != nil) + if case .commit(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/1234567890abcdef1234567890abcdef12345678") + } else { + Issue.record("Expected commit case") + } + } + + /// Test version with no recognizable pattern + @Test func testInvalidVersion() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "unknown-version", + currentCommit: nil + ) + + #expect(notes == nil) + } + + /// Test semantic version with prerelease suffix should not match + @Test func testSemanticVersionWithSuffix() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "1.2.3-beta", + currentCommit: nil + ) + + // Should not match semantic version pattern, falls back to hash detection + #expect(notes == nil) + } + + /// Test semantic version with 4 components should not match + @Test func testSemanticVersionFourComponents() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "1.2.3.4", + currentCommit: nil + ) + + // Should not match pattern + #expect(notes == nil) + } + + /// Test version string with git hash embedded + @Test func testVersionWithEmbeddedHash() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "v2024.01.15-abc1234", + currentCommit: "def5678" + ) + + #expect(notes != nil) + if case .compareTip(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234") + } else { + Issue.record("Expected compareTip case") + } + } +} -- cgit v1.2.3 From a2fbaec6136b6c58f21565c81475386dec55bb5c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 22:18:33 -0700 Subject: macos: do not build updaters into iOS --- macos/Ghostty.xcodeproj/project.pbxproj | 7 +++++++ macos/Sources/Features/Update/UpdateDelegate.swift | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index e53f6d468..558937582 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -125,7 +125,14 @@ "Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift", "Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift", "Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift", + Features/Update/UpdateBadge.swift, + Features/Update/UpdateController.swift, Features/Update/UpdateDelegate.swift, + Features/Update/UpdateDriver.swift, + Features/Update/UpdatePill.swift, + Features/Update/UpdatePopoverView.swift, + Features/Update/UpdateSimulator.swift, + Features/Update/UpdateViewModel.swift, "Ghostty/FullscreenMode+Extension.swift", Ghostty/Ghostty.Command.swift, Ghostty/Ghostty.Error.swift, diff --git a/macos/Sources/Features/Update/UpdateDelegate.swift b/macos/Sources/Features/Update/UpdateDelegate.swift index 4699ba14a..1112c1f44 100644 --- a/macos/Sources/Features/Update/UpdateDelegate.swift +++ b/macos/Sources/Features/Update/UpdateDelegate.swift @@ -6,7 +6,7 @@ class UpdaterDelegate: NSObject, SPUUpdaterDelegate { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } - + // Sparkle supports a native concept of "channels" but it requires that // you share a single appcast file. We don't want to do that so we // do this instead. -- cgit v1.2.3 From bbf875216f1d771daeb4fcc28a1a8c19fe67b43e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Oct 2025 08:51:26 -0700 Subject: macos: fix driver for retry to trigger update check again --- macos/Sources/Features/Update/UpdateDriver.swift | 25 ++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 70f9341a6..5ff29ef75 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -10,7 +10,8 @@ class UpdateDriver: NSObject, SPUUserDriver { super.init() } - func show(_ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { + func show(_ request: SPUUpdatePermissionRequest, + reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { viewModel.state = .permissionRequest(.init(request: request, reply: reply)) } @@ -18,7 +19,9 @@ class UpdateDriver: NSObject, SPUUserDriver { viewModel.state = .checking(.init(cancel: cancellation)) } - func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { + func showUpdateFound(with appcastItem: SUAppcastItem, + state: SPUUserUpdateState, + reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply)) } @@ -31,20 +34,22 @@ class UpdateDriver: NSObject, SPUUserDriver { // We don't do anything with release notes. See `showUpdateReleaseNotes` } - func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) { + func showUpdateNotFoundWithError(_ error: any Error, + acknowledgement: @escaping () -> Void) { viewModel.state = .notFound - // TODO: Do we need to acknowledge? + acknowledgement() } - func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) { + func showUpdaterError(_ error: any Error, + acknowledgement: @escaping () -> Void) { viewModel.state = .error(.init( error: error, - retry: { - guard let delegate = NSApp.delegate as? AppDelegate else { - return + retry: { [weak viewModel] in + viewModel?.state = .idle + DispatchQueue.main.async { + guard let delegate = NSApp.delegate as? AppDelegate else { return } + delegate.checkForUpdates(self) } - - // TODO fill this in }, dismiss: { [weak viewModel] in viewModel?.state = .idle -- cgit v1.2.3 From f2e5b8fb2dd0de1136c208654a6100dfdbd3187c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Oct 2025 08:57:48 -0700 Subject: macos: setup the standard sparkle driver for no-window scenario If there are no windows, we use the standard sparkle driver to drive the standard window-based update UI. --- macos/Sources/Features/Update/UpdateController.swift | 4 +++- macos/Sources/Features/Update/UpdateDriver.swift | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift index 47e6c8def..8dc24698b 100644 --- a/macos/Sources/Features/Update/UpdateController.swift +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -18,7 +18,9 @@ class UpdateController { /// Initialize a new update controller. init() { let hostBundle = Bundle.main - self.userDriver = UpdateDriver(viewModel: .init()) + self.userDriver = UpdateDriver( + viewModel: .init(), + hostBundle: hostBundle) self.updater = SPUUpdater( hostBundle: hostBundle, applicationBundle: hostBundle, diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 5ff29ef75..80064854c 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -4,9 +4,11 @@ import Sparkle /// Implement the SPUUserDriver to modify our UpdateViewModel for custom presentation. class UpdateDriver: NSObject, SPUUserDriver { let viewModel: UpdateViewModel + let standard: SPUStandardUserDriver - init(viewModel: UpdateViewModel) { + init(viewModel: UpdateViewModel, hostBundle: Bundle) { self.viewModel = viewModel + self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil) super.init() } -- cgit v1.2.3 From f124bb4975efaad430ff09aa4243075824cab359 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Oct 2025 17:08:21 -0700 Subject: macos: Fallback to standard driver when no unobtrusive targets exist --- .../Terminal/Window Styles/TerminalWindow.swift | 14 ++++ macos/Sources/Features/Update/UpdateDriver.swift | 95 ++++++++++++++++++++-- .../Sources/Features/Update/UpdateViewModel.swift | 17 ++++ 3 files changed, 121 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 4737bacaf..8ffdc3e35 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -5,6 +5,12 @@ import GhosttyKit /// The base class for all standalone, "normal" terminal windows. This sets the basic /// style and configuration of the window based on the app configuration. class TerminalWindow: NSWindow { + /// Posted when a terminal window awakes from nib. + static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake") + + /// Posted when a terminal window will close + static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose") + /// This is the key in UserDefaults to use for the default `level` value. This is /// used by the manual float on top menu item feature. static let defaultLevelKey: String = "TerminalDefaultLevel" @@ -45,6 +51,9 @@ class TerminalWindow: NSWindow { } override func awakeFromNib() { + // Notify that this terminal window has loaded + NotificationCenter.default.post(name: Self.terminalDidAwake, object: self) + // This is required so that window restoration properly creates our tabs // again. I'm not sure why this is required. If you don't do this, then // tabs restore as separate windows. @@ -124,6 +133,11 @@ class TerminalWindow: NSWindow { // still become key/main and receive events. override var canBecomeKey: Bool { return true } override var canBecomeMain: Bool { return true } + + override func close() { + NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) + super.close() + } override func becomeKey() { super.becomeKey() diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 80064854c..cd1d051e2 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -10,21 +10,56 @@ class UpdateDriver: NSObject, SPUUserDriver { self.viewModel = viewModel self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil) super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTerminalWindowWillClose), + name: TerminalWindow.terminalWillCloseNotification, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func handleTerminalWindowWillClose() { + // If we lost the ability to show unobtrusive states, cancel whatever + // update state we're in. This will allow the manual `check for updates` + // call to initialize the standard driver. + // + // We have to do this after a short delay so that the window can fully + // close. + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in + guard let self else { return } + guard !hasUnobtrusiveTarget else { return } + viewModel.state.cancel() + viewModel.state = .idle + } } func show(_ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { viewModel.state = .permissionRequest(.init(request: request, reply: reply)) + if !hasUnobtrusiveTarget { + standard.show(request, reply: reply) + } } func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { viewModel.state = .checking(.init(cancel: cancellation)) + + if !hasUnobtrusiveTarget { + standard.showUserInitiatedUpdateCheck(cancellation: cancellation) + } } func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply)) + if !hasUnobtrusiveTarget { + standard.showUpdateFound(with: appcastItem, state: state, reply: reply) + } } func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { @@ -39,7 +74,12 @@ class UpdateDriver: NSObject, SPUUserDriver { func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) { viewModel.state = .notFound - acknowledgement() + + if !hasUnobtrusiveTarget { + standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement) + } else { + acknowledgement() + } } func showUpdaterError(_ error: any Error, @@ -56,6 +96,12 @@ class UpdateDriver: NSObject, SPUUserDriver { dismiss: { [weak viewModel] in viewModel?.state = .idle })) + + if !hasUnobtrusiveTarget { + standard.showUpdaterError(error, acknowledgement: acknowledgement) + } else { + acknowledgement() + } } func showDownloadInitiated(cancellation: @escaping () -> Void) { @@ -63,6 +109,10 @@ class UpdateDriver: NSObject, SPUUserDriver { cancel: cancellation, expectedLength: nil, progress: 0)) + + if !hasUnobtrusiveTarget { + standard.showDownloadInitiated(cancellation: cancellation) + } } func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { @@ -74,6 +124,10 @@ class UpdateDriver: NSObject, SPUUserDriver { cancel: downloading.cancel, expectedLength: expectedContentLength, progress: 0)) + + if !hasUnobtrusiveTarget { + standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength) + } } func showDownloadDidReceiveData(ofLength length: UInt64) { @@ -85,36 +139,67 @@ class UpdateDriver: NSObject, SPUUserDriver { cancel: downloading.cancel, expectedLength: downloading.expectedLength, progress: downloading.progress + length)) + + if !hasUnobtrusiveTarget { + standard.showDownloadDidReceiveData(ofLength: length) + } } func showDownloadDidStartExtractingUpdate() { viewModel.state = .extracting(.init(progress: 0)) + + if !hasUnobtrusiveTarget { + standard.showDownloadDidStartExtractingUpdate() + } } func showExtractionReceivedProgress(_ progress: Double) { viewModel.state = .extracting(.init(progress: progress)) + + if !hasUnobtrusiveTarget { + standard.showExtractionReceivedProgress(progress) + } } func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { viewModel.state = .readyToInstall(.init(reply: reply)) + + if !hasUnobtrusiveTarget { + standard.showReady(toInstallAndRelaunch: reply) + } } func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { viewModel.state = .installing + + if !hasUnobtrusiveTarget { + standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication) + } } func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { - // We don't do anything here. + standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement) 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. + if !hasUnobtrusiveTarget { + standard.showUpdateInFocus() + } } func dismissUpdateInstallation() { viewModel.state = .idle + standard.dismissUpdateInstallation() + } + + // MARK: No-Window Fallback + + /// True if there is a target that can render our unobtrusive update checker. + var hasUnobtrusiveTarget: Bool { + NSApp.windows.contains { window in + window is TerminalWindow && + window.isVisible + } } } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 7b6119771..0678997d7 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -134,6 +134,23 @@ enum UpdateState: Equatable { return false } + func cancel() { + switch self { + case .checking(let checking): + checking.cancel() + case .updateAvailable(let available): + available.reply(.dismiss) + case .downloading(let downloading): + downloading.cancel() + case .readyToInstall(let ready): + ready.reply(.dismiss) + case .error(let err): + err.dismiss() + default: + break + } + } + static func == (lhs: UpdateState, rhs: UpdateState) -> Bool { switch (lhs, rhs) { case (.idle, .idle): -- cgit v1.2.3 From ba8eae027e7f5496df37bdd6acf30bf8f8b72854 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 07:18:54 -0700 Subject: macos: fixed width for downloading/extracting, better padding --- macos/Sources/Features/Terminal/TerminalView.swift | 4 ++-- .../Features/Terminal/Window Styles/TerminalWindow.swift | 3 ++- macos/Sources/Features/Update/UpdatePill.swift | 13 ++++++++++++- macos/Sources/Features/Update/UpdateViewModel.swift | 13 +++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 51c4f6ddd..54cf9a02a 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -131,8 +131,8 @@ fileprivate struct UpdateOverlay: View { HStack { Spacer() UpdatePill(model: appDelegate.updateViewModel) - .padding(.bottom, 12) - .padding(.trailing, 12) + .padding(.bottom, 9) + .padding(.trailing, 9) } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 8ffdc3e35..661c89121 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -547,9 +547,10 @@ extension TerminalWindow { @ObservedObject var model: UpdateViewModel var body: some View { + // We use the same top/trailing padding so that it hugs the same. UpdatePill(model: model) .padding(.top, viewModel.accessoryTopPadding) - .padding(.trailing, 10) + .padding(.trailing, viewModel.accessoryTopPadding) } } diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index 1dc29e250..b975e81c9 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -8,6 +8,9 @@ struct UpdatePill: View { /// Whether the update popover is currently visible @State private var showPopover = false + /// The font used for the pill text + private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium) + var body: some View { if !model.state.isIdle { pillButton @@ -43,8 +46,9 @@ struct UpdatePill: View { .frame(width: 14, height: 14) Text(model.text) - .font(.system(size: 11, weight: .medium)) + .font(Font(textFont)) .lineLimit(1) + .frame(width: textWidth) } .padding(.horizontal, 8) .padding(.vertical, 4) @@ -58,4 +62,11 @@ struct UpdatePill: View { .buttonStyle(.plain) .help(model.text) } + + /// Calculated width for the text to prevent resizing during progress updates + private var textWidth: CGFloat? { + let attributes: [NSAttributedString.Key: Any] = [.font: textFont] + let size = (model.maxWidthText as NSString).size(withAttributes: attributes) + return size.width + } } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 0678997d7..6341b3b42 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -37,6 +37,19 @@ class UpdateViewModel: ObservableObject { } } + /// The maximum width text for states that show progress. + /// Used to prevent the pill from resizing as percentages change. + var maxWidthText: String { + switch state { + case .downloading: + return "Downloading: 100%" + case .extracting: + return "Preparing: 100%" + default: + return text + } + } + /// The SF Symbol icon name for the current update state. /// Returns nil for idle, downloading, and extracting states. var iconName: String? { -- cgit v1.2.3 From 6993947a3a8a8c92d849fa1fa23a9e9fa4016ea8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 07:11:22 -0700 Subject: macOS: Make a lot of things more robust --- macos/Sources/App/macOS/AppDelegate.swift | 4 ++-- macos/Sources/Features/Update/UpdateBadge.swift | 4 ++-- macos/Sources/Features/Update/UpdateController.swift | 17 +++++++++++++++-- macos/Sources/Features/Update/UpdateDriver.swift | 5 +++-- macos/Sources/Features/Update/UpdatePill.swift | 13 +++++++++---- macos/Sources/Features/Update/UpdatePopoverView.swift | 6 +++--- 6 files changed, 34 insertions(+), 15 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 216373e7e..cf717993a 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -998,8 +998,8 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - updateController.checkForUpdates() - //UpdateSimulator.permissionRequest.simulate(with: updateViewModel) + //updateController.checkForUpdates() + UpdateSimulator.happyPath.simulate(with: updateViewModel) } diff --git a/macos/Sources/Features/Update/UpdateBadge.swift b/macos/Sources/Features/Update/UpdateBadge.swift index fd1eb3498..afd0849be 100644 --- a/macos/Sources/Features/Update/UpdateBadge.swift +++ b/macos/Sources/Features/Update/UpdateBadge.swift @@ -17,14 +17,14 @@ struct UpdateBadge: View { switch model.state { case .downloading(let download): if let expectedLength = download.expectedLength, expectedLength > 0 { - let progress = Double(download.progress) / Double(expectedLength) + let progress = min(1, max(0, Double(download.progress) / Double(expectedLength))) ProgressRingView(progress: progress) } else { Image(systemName: "arrow.down.circle") } case .extracting(let extracting): - ProgressRingView(progress: extracting.progress) + ProgressRingView(progress: min(1, max(0, extracting.progress))) case .checking, .installing: if let iconName = model.iconName { diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift index 8dc24698b..446b82ebc 100644 --- a/macos/Sources/Features/Update/UpdateController.swift +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -32,9 +32,22 @@ class UpdateController { /// Start the updater. /// /// This must be called before the updater can check for updates. If starting fails, - /// an error alert will be shown after a short delay. + /// the error will be shown to the user. func startUpdater() { - try? updater.start() + do { + try updater.start() + } catch { + userDriver.viewModel.state = .error(.init( + error: error, + retry: { [weak self] in + self?.userDriver.viewModel.state = .idle + self?.startUpdater() + }, + dismiss: { [weak self] in + self?.userDriver.viewModel.state = .idle + } + )) + } } /// Check for updates. diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index cd1d051e2..9196d9ad9 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -86,9 +86,10 @@ class UpdateDriver: NSObject, SPUUserDriver { acknowledgement: @escaping () -> Void) { viewModel.state = .error(.init( error: error, - retry: { [weak viewModel] in + retry: { [weak self, weak viewModel] in viewModel?.state = .idle - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + guard let self else { return } guard let delegate = NSApp.delegate as? AppDelegate else { return } delegate.checkForUpdates(self) } diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index b975e81c9..3b48ac218 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -8,6 +8,9 @@ struct UpdatePill: View { /// Whether the update popover is currently visible @State private var showPopover = false + /// Task for auto-dismissing the "No Updates" state + @State private var resetTask: Task? + /// The font used for the pill text private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium) @@ -19,13 +22,15 @@ struct UpdatePill: View { } .transition(.opacity.combined(with: .scale(scale: 0.95))) .onChange(of: model.state) { newState in + resetTask?.cancel() if case .notFound = newState { - Task { + resetTask = Task { [weak model] in try? await Task.sleep(for: .seconds(5)) - if case .notFound = model.state { - model.state = .idle - } + guard !Task.isCancelled, case .notFound? = model?.state else { return } + model?.state = .idle } + } else { + resetTask = nil } } } diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index a73116ca0..7634d27de 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -228,7 +228,7 @@ fileprivate struct DownloadingView: View { .font(.system(size: 13, weight: .semibold)) if let expectedLength = download.expectedLength, expectedLength > 0 { - let progress = Double(download.progress) / Double(expectedLength) + let progress = min(1, max(0, Double(download.progress) / Double(expectedLength))) VStack(alignment: .leading, spacing: 6) { ProgressView(value: progress) Text(String(format: "%.0f%%", progress * 100)) @@ -264,8 +264,8 @@ fileprivate struct ExtractingView: View { .font(.system(size: 13, weight: .semibold)) VStack(alignment: .leading, spacing: 6) { - ProgressView(value: extracting.progress, total: 1.0) - Text(String(format: "%.0f%%", extracting.progress * 100)) + ProgressView(value: min(1, max(0, extracting.progress)), total: 1.0) + Text(String(format: "%.0f%%", min(1, max(0, extracting.progress)) * 100)) .font(.system(size: 11)) .foregroundColor(.secondary) } -- cgit v1.2.3 From 47f3c946401529ec7b2c90405be46ab7a4123629 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 08:34:42 -0700 Subject: macos: many more unit tests for update work --- AGENTS.md | 1 + macos/Tests/Update/UpdateStateTests.swift | 116 ++++++++++++++++++++++++++ macos/Tests/Update/UpdateViewModelTests.swift | 97 +++++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 macos/Tests/Update/UpdateStateTests.swift create mode 100644 macos/Tests/Update/UpdateViewModelTests.swift diff --git a/AGENTS.md b/AGENTS.md index afa0fd1f2..5a885923e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,3 +29,4 @@ A file for [guiding coding agents](https://agents.md/). - Do not use `xcodebuild` - Use `zig build` to build the macOS app and any shared Zig code +- Run Xcode tests using `zig build test` diff --git a/macos/Tests/Update/UpdateStateTests.swift b/macos/Tests/Update/UpdateStateTests.swift new file mode 100644 index 000000000..5a0832a5a --- /dev/null +++ b/macos/Tests/Update/UpdateStateTests.swift @@ -0,0 +1,116 @@ +import Testing +import Foundation +import Sparkle +@testable import Ghostty + +struct UpdateStateTests { + // MARK: - Equatable Tests + + @Test func testIdleEquality() { + let state1: UpdateState = .idle + let state2: UpdateState = .idle + #expect(state1 == state2) + } + + @Test func testCheckingEquality() { + let state1: UpdateState = .checking(.init(cancel: {})) + let state2: UpdateState = .checking(.init(cancel: {})) + #expect(state1 == state2) + } + + @Test func testNotFoundEquality() { + let state1: UpdateState = .notFound + let state2: UpdateState = .notFound + #expect(state1 == state2) + } + + @Test func testInstallingEquality() { + let state1: UpdateState = .installing + let state2: UpdateState = .installing + #expect(state1 == state2) + } + + @Test func testPermissionRequestEquality() { + let request1 = SPUUpdatePermissionRequest(systemProfile: []) + let request2 = SPUUpdatePermissionRequest(systemProfile: []) + let state1: UpdateState = .permissionRequest(.init(request: request1, reply: { _ in })) + let state2: UpdateState = .permissionRequest(.init(request: request2, reply: { _ in })) + #expect(state1 == state2) + } + + @Test func testReadyToInstallEquality() { + let state1: UpdateState = .readyToInstall(.init(reply: { _ in })) + let state2: UpdateState = .readyToInstall(.init(reply: { _ in })) + #expect(state1 == state2) + } + + @Test func testDownloadingEqualityWithSameProgress() { + let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + #expect(state1 == state2) + } + + @Test func testDownloadingInequalityWithDifferentProgress() { + let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 600)) + #expect(state1 != state2) + } + + @Test func testDownloadingInequalityWithDifferentExpectedLength() { + let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 2000, progress: 500)) + #expect(state1 != state2) + } + + @Test func testDownloadingEqualityWithNilExpectedLength() { + let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500)) + let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500)) + #expect(state1 == state2) + } + + @Test func testExtractingEqualityWithSameProgress() { + let state1: UpdateState = .extracting(.init(progress: 0.5)) + let state2: UpdateState = .extracting(.init(progress: 0.5)) + #expect(state1 == state2) + } + + @Test func testExtractingInequalityWithDifferentProgress() { + let state1: UpdateState = .extracting(.init(progress: 0.5)) + let state2: UpdateState = .extracting(.init(progress: 0.6)) + #expect(state1 != state2) + } + + @Test func testErrorEqualityWithSameDescription() { + let error1 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error message"]) + let error2 = NSError(domain: "Test", code: 2, userInfo: [NSLocalizedDescriptionKey: "Error message"]) + let state1: UpdateState = .error(.init(error: error1, retry: {}, dismiss: {})) + let state2: UpdateState = .error(.init(error: error2, retry: {}, dismiss: {})) + #expect(state1 == state2) + } + + @Test func testErrorInequalityWithDifferentDescription() { + let error1 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error 1"]) + let error2 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error 2"]) + let state1: UpdateState = .error(.init(error: error1, retry: {}, dismiss: {})) + let state2: UpdateState = .error(.init(error: error2, retry: {}, dismiss: {})) + #expect(state1 != state2) + } + + @Test func testDifferentStatesAreNotEqual() { + let state1: UpdateState = .idle + let state2: UpdateState = .checking(.init(cancel: {})) + #expect(state1 != state2) + } + + // MARK: - isIdle Tests + + @Test func testIsIdleTrue() { + let state: UpdateState = .idle + #expect(state.isIdle == true) + } + + @Test func testIsIdleFalse() { + let state: UpdateState = .checking(.init(cancel: {})) + #expect(state.isIdle == false) + } +} diff --git a/macos/Tests/Update/UpdateViewModelTests.swift b/macos/Tests/Update/UpdateViewModelTests.swift new file mode 100644 index 000000000..dd88cbe83 --- /dev/null +++ b/macos/Tests/Update/UpdateViewModelTests.swift @@ -0,0 +1,97 @@ +import Testing +import Foundation +import SwiftUI +import Sparkle +@testable import Ghostty + +struct UpdateViewModelTests { + // MARK: - Text Formatting Tests + + @Test func testIdleText() { + let viewModel = UpdateViewModel() + viewModel.state = .idle + #expect(viewModel.text == "") + } + + @Test func testPermissionRequestText() { + let viewModel = UpdateViewModel() + let request = SPUUpdatePermissionRequest(systemProfile: []) + viewModel.state = .permissionRequest(.init(request: request, reply: { _ in })) + #expect(viewModel.text == "Enable Automatic Updates?") + } + + @Test func testCheckingText() { + let viewModel = UpdateViewModel() + viewModel.state = .checking(.init(cancel: {})) + #expect(viewModel.text == "Checking for Updates…") + } + + @Test func testDownloadingTextWithKnownLength() { + let viewModel = UpdateViewModel() + viewModel.state = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + #expect(viewModel.text == "Downloading: 50%") + } + + @Test func testDownloadingTextWithUnknownLength() { + let viewModel = UpdateViewModel() + viewModel.state = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500)) + #expect(viewModel.text == "Downloading…") + } + + @Test func testDownloadingTextWithZeroExpectedLength() { + let viewModel = UpdateViewModel() + viewModel.state = .downloading(.init(cancel: {}, expectedLength: 0, progress: 500)) + #expect(viewModel.text == "Downloading…") + } + + @Test func testExtractingText() { + let viewModel = UpdateViewModel() + viewModel.state = .extracting(.init(progress: 0.75)) + #expect(viewModel.text == "Preparing: 75%") + } + + @Test func testReadyToInstallText() { + let viewModel = UpdateViewModel() + viewModel.state = .readyToInstall(.init(reply: { _ in })) + #expect(viewModel.text == "Install Update") + } + + @Test func testInstallingText() { + let viewModel = UpdateViewModel() + viewModel.state = .installing + #expect(viewModel.text == "Installing…") + } + + @Test func testNotFoundText() { + let viewModel = UpdateViewModel() + viewModel.state = .notFound + #expect(viewModel.text == "No Updates Available") + } + + @Test func testErrorText() { + let viewModel = UpdateViewModel() + let error = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Network error"]) + viewModel.state = .error(.init(error: error, retry: {}, dismiss: {})) + #expect(viewModel.text == "Network error") + } + + // MARK: - Max Width Text Tests + + @Test func testMaxWidthTextForDownloading() { + let viewModel = UpdateViewModel() + viewModel.state = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 50)) + #expect(viewModel.maxWidthText == "Downloading: 100%") + } + + @Test func testMaxWidthTextForExtracting() { + let viewModel = UpdateViewModel() + viewModel.state = .extracting(.init(progress: 0.5)) + #expect(viewModel.maxWidthText == "Preparing: 100%") + } + + @Test func testMaxWidthTextForNonProgressState() { + let viewModel = UpdateViewModel() + viewModel.state = .checking(.init(cancel: {})) + #expect(viewModel.maxWidthText == viewModel.text) + } +} -- cgit v1.2.3 From 9dac88248f9761ef69ecb522ce8249b27b513d1c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 08:38:08 -0700 Subject: macos: ax for update info --- macos/Sources/Features/Update/UpdateBadge.swift | 10 ++++++++++ macos/Sources/Features/Update/UpdatePill.swift | 2 ++ 2 files changed, 12 insertions(+) diff --git a/macos/Sources/Features/Update/UpdateBadge.swift b/macos/Sources/Features/Update/UpdateBadge.swift index afd0849be..a4a95f411 100644 --- a/macos/Sources/Features/Update/UpdateBadge.swift +++ b/macos/Sources/Features/Update/UpdateBadge.swift @@ -14,6 +14,12 @@ struct UpdateBadge: View { @State private var rotationAngle: Double = 0 var body: some View { + badgeContent + .accessibilityLabel(model.text) + } + + @ViewBuilder + private var badgeContent: some View { switch model.state { case .downloading(let download): if let expectedLength = download.expectedLength, expectedLength > 0 { @@ -38,11 +44,15 @@ struct UpdateBadge: View { .onDisappear { rotationAngle = 0 } + } else { + EmptyView() } default: if let iconName = model.iconName { Image(systemName: iconName) + } else { + EmptyView() } } } diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index 3b48ac218..ff4af97dd 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -53,6 +53,7 @@ struct UpdatePill: View { Text(model.text) .font(Font(textFont)) .lineLimit(1) + .truncationMode(.tail) .frame(width: textWidth) } .padding(.horizontal, 8) @@ -66,6 +67,7 @@ struct UpdatePill: View { } .buttonStyle(.plain) .help(model.text) + .accessibilityLabel(model.text) } /// Calculated width for the text to prevent resizing during progress updates -- cgit v1.2.3 From e0ee10e9025614c8a6c35768e51c1659eed11b85 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 08:44:24 -0700 Subject: macos: re-enable real update check --- macos/Sources/App/macOS/AppDelegate.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index cf717993a..3eff7b3e4 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -998,8 +998,8 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - //updateController.checkForUpdates() - UpdateSimulator.happyPath.simulate(with: updateViewModel) + updateController.checkForUpdates() + //UpdateSimulator.happyPath.simulate(with: updateViewModel) } -- cgit v1.2.3