macOS: Unobtrusive update views

This commit is contained in:
Mitchell Hashimoto
2025-10-08 08:43:39 -07:00
parent 67ece53423
commit 09ba5a27a2
6 changed files with 814 additions and 12 deletions

View File

@@ -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?) {

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}