From 09ba5a27a234bfe5c8cad1c51da7b5028f239f1b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 08:43:39 -0700 Subject: [PATCH] macOS: Unobtrusive update views --- macos/Sources/App/macOS/AppDelegate.swift | 30 +- .../Window Styles/TerminalWindow.swift | 140 ++++++- .../Sources/Features/Update/UpdateBadge.swift | 65 ++++ .../Sources/Features/Update/UpdatePill.swift | 51 +++ .../Features/Update/UpdatePopoverView.swift | 362 ++++++++++++++++++ .../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 + + /// Calculates the top padding based on toolbar visibility and macOS version + fileprivate var accessoryTopPadding: CGFloat { + if #available(macOS 26.0, *) { + return hasToolbar ? 10 : 5 + } else { + return hasToolbar ? 9 : 4 + } + } } 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 { - if #available(macOS 26.0, *) { - return viewModel.hasToolbar ? 10 : 5 - } else { - return viewModel.hasToolbar ? 9 : 4 - } - } 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 + } + } +}