summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md1
-rw-r--r--macos/Ghostty.xcodeproj/project.pbxproj7
-rw-r--r--macos/Sources/App/macOS/AppDelegate.swift30
-rw-r--r--macos/Sources/Features/QuickTerminal/QuickTerminalController.swift2
-rw-r--r--macos/Sources/Features/Terminal/BaseTerminalController.swift38
-rw-r--r--macos/Sources/Features/Terminal/TerminalView.swift25
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift3
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift77
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift4
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift4
-rw-r--r--macos/Sources/Features/Update/UpdateBadge.swift83
-rw-r--r--macos/Sources/Features/Update/UpdateController.swift70
-rw-r--r--macos/Sources/Features/Update/UpdateDelegate.swift2
-rw-r--r--macos/Sources/Features/Update/UpdateDriver.swift206
-rw-r--r--macos/Sources/Features/Update/UpdatePill.swift79
-rw-r--r--macos/Sources/Features/Update/UpdatePopoverView.swift402
-rw-r--r--macos/Sources/Features/Update/UpdateSimulator.swift275
-rw-r--r--macos/Sources/Features/Update/UpdateViewModel.swift297
-rw-r--r--macos/Tests/Update/ReleaseNotesTests.swift130
-rw-r--r--macos/Tests/Update/UpdateStateTests.swift116
-rw-r--r--macos/Tests/Update/UpdateViewModelTests.swift97
21 files changed, 1915 insertions, 33 deletions
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/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/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift
index 942aecdd4..3eff7b3e4 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
@@ -98,8 +99,10 @@ class AppDelegate: NSObject,
)
/// Manages updates
- let updaterController: SPUStandardUpdaterController
- let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
+ let updateController = UpdateController()
+ var updateViewModel: UpdateViewModel {
+ updateController.viewModel
+ }
/// The elapsed time since the process was started
var timeSinceLaunch: TimeInterval {
@@ -126,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
@@ -179,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()
@@ -806,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
}
@@ -1004,9 +998,11 @@ class AppDelegate: NSObject,
}
@IBAction func checkForUpdates(_ sender: Any?) {
- updaterController.checkForUpdates(sender)
+ updateController.checkForUpdates()
+ //UpdateSimulator.happyPath.simulate(with: updateViewModel)
}
+
@IBAction func newWindow(_ sender: Any?) {
_ = TerminalController.newWindow(ghostty)
}
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 b5be0ae42..54cf9a02a 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.
@@ -109,6 +112,28 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
self.delegate?.performAction(action, on: surfaceView)
}
}
+
+ // Show update information above all else.
+ if viewModel.updateOverlayIsVisible {
+ UpdateOverlay()
+ }
+ }
+ }
+ }
+}
+
+fileprivate struct UpdateOverlay: View {
+ var body: some View {
+ if let appDelegate = NSApp.delegate as? AppDelegate {
+ VStack {
+ Spacer()
+
+ HStack {
+ Spacer()
+ UpdatePill(model: appDelegate.updateViewModel)
+ .padding(.bottom, 9)
+ .padding(.trailing, 9)
+ }
}
}
}
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 3ab6293dc..661c89121 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"
@@ -14,15 +20,25 @@ class TerminalWindow: NSWindow {
/// Reset split zoom button in titlebar
private let resetZoomAccessory = NSTitlebarAccessoryViewController()
+
+ /// Update notification UI in titlebar
+ private let updateAccessory = NSTitlebarAccessoryViewController()
/// 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? {
@@ -35,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.
@@ -85,6 +104,17 @@ class TerminalWindow: NSWindow {
}))
addTitlebarAccessoryViewController(resetZoomAccessory)
resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
+
+ // Create update notification accessory
+ if supportsUpdateAccessory {
+ updateAccessory.layoutAttribute = .right
+ updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView(
+ viewModel: viewModel,
+ model: appDelegate.updateViewModel
+ ))
+ addTitlebarAccessoryViewController(updateAccessory)
+ updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false
+ }
}
// Setup the accessory view for tabs that shows our keyboard shortcuts,
@@ -103,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()
@@ -198,6 +233,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,7 +474,7 @@ class TerminalWindow: NSWindow {
standardWindowButton(.miniaturizeButton)?.isHidden = true
standardWindowButton(.zoomButton)?.isHidden = true
}
-
+
// MARK: Config
struct DerivedConfig {
@@ -467,21 +505,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 +534,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
+
+ 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, viewModel.accessoryTopPadding)
+ }
+ }
+
}
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
diff --git a/macos/Sources/Features/Update/UpdateBadge.swift b/macos/Sources/Features/Update/UpdateBadge.swift
new file mode 100644
index 000000000..a4a95f411
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdateBadge.swift
@@ -0,0 +1,83 @@
+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 {
+ badgeContent
+ .accessibilityLabel(model.text)
+ }
+
+ @ViewBuilder
+ private var badgeContent: some View {
+ switch model.state {
+ case .downloading(let download):
+ if let expectedLength = download.expectedLength, expectedLength > 0 {
+ 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: min(1, max(0, extracting.progress)))
+
+ case .checking, .installing:
+ 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
+ }
+ } else {
+ EmptyView()
+ }
+
+ default:
+ if let iconName = model.iconName {
+ Image(systemName: iconName)
+ } else {
+ EmptyView()
+ }
+ }
+ }
+}
+
+/// 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/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift
new file mode 100644
index 000000000..446b82ebc
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdateController.swift
@@ -0,0 +1,70 @@
+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(),
+ hostBundle: hostBundle)
+ 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,
+ /// the error will be shown to the user.
+ func startUpdater() {
+ 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.
+ ///
+ /// 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/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.
diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift
new file mode 100644
index 000000000..9196d9ad9
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdateDriver.swift
@@ -0,0 +1,206 @@
+import Cocoa
+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, hostBundle: Bundle) {
+ 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) {
+ // 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
+
+ if !hasUnobtrusiveTarget {
+ standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement)
+ } else {
+ acknowledgement()
+ }
+ }
+
+ func showUpdaterError(_ error: any Error,
+ acknowledgement: @escaping () -> Void) {
+ viewModel.state = .error(.init(
+ error: error,
+ retry: { [weak self, weak viewModel] in
+ viewModel?.state = .idle
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ guard let delegate = NSApp.delegate as? AppDelegate else { return }
+ delegate.checkForUpdates(self)
+ }
+ },
+ dismiss: { [weak viewModel] in
+ viewModel?.state = .idle
+ }))
+
+ if !hasUnobtrusiveTarget {
+ standard.showUpdaterError(error, acknowledgement: acknowledgement)
+ } else {
+ acknowledgement()
+ }
+ }
+
+ func showDownloadInitiated(cancellation: @escaping () -> Void) {
+ viewModel.state = .downloading(.init(
+ cancel: cancellation,
+ expectedLength: nil,
+ progress: 0))
+
+ if !hasUnobtrusiveTarget {
+ standard.showDownloadInitiated(cancellation: cancellation)
+ }
+ }
+
+ func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
+ guard case let .downloading(downloading) = viewModel.state else {
+ return
+ }
+
+ viewModel.state = .downloading(.init(
+ cancel: downloading.cancel,
+ expectedLength: expectedContentLength,
+ progress: 0))
+
+ if !hasUnobtrusiveTarget {
+ standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength)
+ }
+ }
+
+ 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))
+
+ 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) {
+ standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement)
+ viewModel.state = .idle
+ }
+
+ func showUpdateInFocus() {
+ 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/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift
new file mode 100644
index 000000000..ff4af97dd
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdatePill.swift
@@ -0,0 +1,79 @@
+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
+
+ /// 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<Void, Never>?
+
+ /// 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
+ .popover(isPresented: $showPopover, arrowEdge: .bottom) {
+ UpdatePopoverView(model: model)
+ }
+ .transition(.opacity.combined(with: .scale(scale: 0.95)))
+ .onChange(of: model.state) { newState in
+ resetTask?.cancel()
+ if case .notFound = newState {
+ resetTask = Task { [weak model] in
+ try? await Task.sleep(for: .seconds(5))
+ guard !Task.isCancelled, case .notFound? = model?.state else { return }
+ model?.state = .idle
+ }
+ } else {
+ resetTask = nil
+ }
+ }
+ }
+ }
+
+ /// The pill-shaped button view that displays the update badge and text
+ @ViewBuilder
+ private var pillButton: some View {
+ Button(action: {
+ if case .notFound = model.state {
+ model.state = .idle
+ } else {
+ showPopover.toggle()
+ }
+ }) {
+ HStack(spacing: 6) {
+ UpdateBadge(model: model)
+ .frame(width: 14, height: 14)
+
+ Text(model.text)
+ .font(Font(textFont))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .frame(width: textWidth)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(
+ Capsule()
+ .fill(model.backgroundColor)
+ )
+ .foregroundColor(model.foregroundColor)
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .help(model.text)
+ .accessibilityLabel(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/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift
new file mode 100644
index 000000000..7634d27de
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdatePopoverView.swift
@@ -0,0 +1,402 @@
+import SwiftUI
+import Sparkle
+
+/// 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
+
+ /// 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:
+ // Shouldn't happen in a well-formed view stack. Higher levels
+ // should not call the popover for idles.
+ EmptyView()
+
+ case .permissionRequest(let request):
+ PermissionRequestView(request: request, dismiss: dismiss)
+
+ case .checking(let checking):
+ CheckingView(checking: checking, dismiss: dismiss)
+
+ case .updateAvailable(let update):
+ UpdateAvailableView(update: update, dismiss: dismiss)
+
+ case .downloading(let download):
+ DownloadingView(download: download, dismiss: dismiss)
+
+ case .extracting(let extracting):
+ ExtractingView(extracting: extracting)
+
+ case .readyToInstall(let ready):
+ ReadyToInstallView(ready: ready, dismiss: dismiss)
+
+ case .installing:
+ InstallingView()
+
+ case .notFound:
+ NotFoundView(dismiss: dismiss)
+
+ case .error(let error):
+ UpdateErrorView(error: error, dismiss: dismiss)
+ }
+ }
+ .frame(width: 300)
+ }
+}
+
+fileprivate struct PermissionRequestView: View {
+ let request: UpdateState.PermissionRequest
+ let dismiss: DismissAction
+
+ var body: 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 updates in the background.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ HStack(spacing: 8) {
+ Button("Not Now") {
+ request.reply(SUUpdatePermissionResponse(
+ automaticUpdateChecks: false,
+ sendSystemProfile: false))
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+
+ Spacer()
+
+ Button("Allow") {
+ request.reply(SUUpdatePermissionResponse(
+ automaticUpdateChecks: true,
+ sendSystemProfile: false))
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ .padding(16)
+ }
+}
+
+fileprivate struct CheckingView: View {
+ let checking: UpdateState.Checking
+ let dismiss: DismissAction
+
+ var body: 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") {
+ checking.cancel()
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+}
+
+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) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Update Available")
+ .font(.system(size: 13, weight: .semibold))
+
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 6) {
+ Text("Version:")
+ .foregroundColor(.secondary)
+ .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) {
+ Button("Skip") {
+ update.reply(.skip)
+ dismiss()
+ }
+ .controlSize(.small)
+
+ Button("Later") {
+ update.reply(.dismiss)
+ dismiss()
+ }
+ .controlSize(.small)
+ .keyboardShortcut(.cancelAction)
+
+ Spacer()
+
+ Button("Install") {
+ update.reply(.install)
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+ }
+ .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)
+ }
+ }
+ }
+}
+
+fileprivate struct DownloadingView: View {
+ let download: UpdateState.Downloading
+ let dismiss: DismissAction
+
+ 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 expectedLength = download.expectedLength, expectedLength > 0 {
+ 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))
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+ } else {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ HStack {
+ Spacer()
+ Button("Cancel") {
+ download.cancel()
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+}
+
+fileprivate struct ExtractingView: View {
+ let extracting: UpdateState.Extracting
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Preparing Update")
+ .font(.system(size: 13, weight: .semibold))
+
+ VStack(alignment: .leading, spacing: 6) {
+ 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)
+ }
+ }
+ .padding(16)
+ }
+}
+
+fileprivate struct ReadyToInstallView: View {
+ let ready: UpdateState.ReadyToInstall
+ let dismiss: DismissAction
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Ready to Install")
+ .font(.system(size: 13, weight: .semibold))
+
+ Text("The update is ready to install.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+
+ HStack(spacing: 8) {
+ Button("Later") {
+ ready.reply(.dismiss)
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .controlSize(.small)
+
+ Spacer()
+
+ Button("Install and Relaunch") {
+ ready.reply(.install)
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+}
+
+fileprivate struct InstallingView: View {
+ var body: 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)
+ }
+}
+
+fileprivate struct NotFoundView: View {
+ let dismiss: DismissAction
+
+ var body: 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") {
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+}
+
+fileprivate struct UpdateErrorView: View {
+ let error: UpdateState.Error
+ let dismiss: DismissAction
+
+ 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("Update Failed")
+ .font(.system(size: 13, weight: .semibold))
+ }
+
+ Text(error.error.localizedDescription)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ HStack(spacing: 8) {
+ Button("OK") {
+ error.dismiss()
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .controlSize(.small)
+
+ Spacer()
+
+ Button("Retry") {
+ error.retry()
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+}
diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift
new file mode 100644
index 000000000..96fab4835
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdateSimulator.swift
@@ -0,0 +1,275 @@
+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)
+ },
+ dismiss: {
+ viewModel.state = .idle
+ }
+ ))
+ }
+ }
+
+ 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
+ }
+ }
+ ))
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift
new file mode 100644
index 000000000..6341b3b42
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdateViewModel.swift
@@ -0,0 +1,297 @@
+import Foundation
+import SwiftUI
+import Sparkle
+
+class UpdateViewModel: ObservableObject {
+ @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:
+ return ""
+ case .permissionRequest:
+ return "Enable Automatic Updates?"
+ case .checking:
+ return "Checking for Updates…"
+ 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(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(let err):
+ return err.error.localizedDescription
+ }
+ }
+
+ /// 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? {
+ switch state {
+ case .idle:
+ return nil
+ case .permissionRequest:
+ return "questionmark.circle"
+ case .checking:
+ return "arrow.triangle.2.circlepath"
+ case .updateAvailable:
+ return "arrow.down.circle.fill"
+ case .downloading, .extracting:
+ return nil
+ case .readyToInstall:
+ return "checkmark.circle.fill"
+ case .installing:
+ return "gear"
+ case .notFound:
+ return "info.circle"
+ case .error:
+ return "exclamationmark.triangle.fill"
+ }
+ }
+
+ /// The color to apply to the icon for the current update state.
+ var iconColor: Color {
+ switch state {
+ case .idle:
+ return .secondary
+ case .permissionRequest:
+ return .white
+ case .checking:
+ return .secondary
+ case .updateAvailable, .readyToInstall:
+ return .accentColor
+ case .downloading, .extracting, .installing:
+ return .secondary
+ case .notFound:
+ return .secondary
+ case .error:
+ return .orange
+ }
+ }
+
+ /// 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:
+ 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:
+ return Color(nsColor: .controlBackgroundColor)
+ }
+ }
+
+ /// The foreground (text) color for the update pill.
+ var foregroundColor: Color {
+ switch state {
+ case .permissionRequest:
+ return .white
+ case .updateAvailable, .readyToInstall:
+ return .white
+ case .notFound:
+ return .white
+ case .error:
+ return .orange
+ default:
+ return .primary
+ }
+ }
+}
+
+enum UpdateState: Equatable {
+ 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
+ }
+
+ 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):
+ 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
+ }
+
+ struct Checking {
+ let cancel: () -> Void
+ }
+
+ 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 {
+ let error: any Swift.Error
+ let retry: () -> Void
+ let dismiss: () -> Void
+ }
+
+ struct Downloading {
+ let cancel: () -> Void
+ let expectedLength: UInt64?
+ let progress: UInt64
+ }
+
+ struct Extracting {
+ let progress: Double
+ }
+
+ struct ReadyToInstall {
+ let reply: @Sendable (SPUUserUpdateChoice) -> Void
+ }
+}
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")
+ }
+ }
+}
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)
+ }
+}