macOS: suppress restart tips for auto update (#13623)

With this, users will not be prompted to restart the app when an
automatic update is ready. Relaunching with this state will have no
visible difference to users.

If the user checks for updates manually, either by menu, command
palette, or keybind, a restart alert will be prompted.

Closes #13478

Auto updates:


https://github.com/user-attachments/assets/f77f65a1-6e2f-4002-961c-fee4b73d447e

Manual Updates: 


https://github.com/user-attachments/assets/8a52a5a0-2022-413f-97c5-6f9a2eb26e7a
This commit is contained in:
Mitchell Hashimoto
2026-08-05 06:57:18 -07:00
committed by GitHub
9 changed files with 149 additions and 95 deletions

View File

@@ -376,7 +376,7 @@ class AppDelegate: NSObject,
// If we've already accepted to install an update, then we don't need to
// confirm quit. The user is already expecting the update to happen.
if updateController.isInstalling {
if updateController.shouldTerminateWithoutWarning {
return .terminateNow
}

View File

@@ -1,6 +1,7 @@
import Sparkle
import Cocoa
import Combine
import SwiftUI
/// Standard controller for managing Sparkle updates in Ghostty.
///
@@ -15,13 +16,9 @@ class UpdateController {
userDriver.viewModel
}
/// True if we're installing an update.
var isInstalling: Bool {
if case .installing = viewModel.state {
return true
} else {
return false
}
/// True if we're installing an update triggered manually.
var shouldTerminateWithoutWarning: Bool {
viewModel.state.shouldTerminateWithoutWarning
}
/// Initialize a new update controller.
@@ -69,6 +66,30 @@ class UpdateController {
return
}
if case let .installing(installing) = viewModel.state {
// If the update is already installed, we can't actually
// cancel it, and SPUUpdater.checkForUpdates will simply fail,
// so we just show an alert to remind the user to restart.
let alert = NSAlert()
alert.alertStyle = .informational
let accessoryView = NSHostingView(
rootView: InstallingAccessoryView(installing: installing)
.frame(width: 228, alignment: .leading)
)
accessoryView.frame = .init(origin: .zero, size: accessoryView.fittingSize)
alert.accessoryView = accessoryView
alert.addButton(withTitle: "Restart Now")
alert.addButton(withTitle: "Restart Later")
.keyEquivalent = .init([KeyboardShortcut(.escape).key.character])
switch alert.runModal() {
case .alertFirstButtonReturn:
viewModel.state.confirm()
default:
break
}
return
}
// If we're not idle then we need to cancel any prior state.
viewModel.state.cancel()
@@ -91,3 +112,46 @@ class UpdateController {
return true
}
}
private struct InstallingAccessoryView: View {
let installing: UpdateState.Installing
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Restart Required")
.font(.system(size: 13, weight: .semibold))
Text("The update is ready. Please restart the application to complete the installation.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let item = installing.appcastItem, let releaseNotesURL = installing.releaseNotes?.url {
VStack(alignment: .leading, spacing: 4) {
Link(destination: releaseNotesURL) {
HStack(spacing: 6) {
Text("Version:")
.foregroundColor(.secondary)
.frame(width: 60, alignment: .trailing)
Text(item.displayVersionString)
}
.font(.system(size: 11))
}
if let date = item.date {
HStack(spacing: 6) {
Text("Released:")
.foregroundColor(.secondary)
.frame(width: 60, alignment: .trailing)
Text(date.formatted(date: .abbreviated, time: .omitted))
}
.font(.system(size: 11))
}
}
.textSelection(.enabled)
}
}
}
}
}

View File

@@ -23,12 +23,12 @@ extension UpdateDriver: SPUUpdaterDelegate {
/// delegate method on the responsible driver instead.
func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool {
viewModel.state = .installing(.init(
isAutoUpdate: true,
retryTerminatingApplication: immediateInstallHandler,
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}
appcastItem: item,
retryTerminatingApplication: immediateInstallHandler
))
AppDelegate.logger.info("Version: \(item.displayVersionString) installed silently, waiting for relaunch...")
// Even when hasUnobtrusiveTarget is false, we don't show the alert immediately.
// We wait until the user manually checks for updates or relaunches.
return true
}
}

View File

@@ -171,10 +171,8 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
viewModel.state = .installing(.init(
appcastItem: nil,
retryTerminatingApplication: retryTerminatingApplication,
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}
))
if !hasUnobtrusiveTarget {

View File

@@ -35,11 +35,8 @@ struct UpdatePopoverView: View {
case .extracting(let extracting):
ExtractingView(extracting: extracting)
case .installing(let installing):
// This is only required when `installing.isAutoUpdate == true`,
// but we keep it anyway, just in case something unexpected
// happens during installing
InstallingView(installing: installing, dismiss: dismiss)
case .installing:
EmptyView()
case .notFound(let notFound):
NotFoundView(notFound: notFound, dismiss: dismiss)
@@ -274,45 +271,6 @@ private struct ExtractingView: View {
}
}
private struct InstallingView: View {
let installing: UpdateState.Installing
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Restart Required")
.font(.system(size: 13, weight: .semibold))
Text("The update is ready. Please restart the application to complete the installation.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Button("Restart Later") {
installing.dismiss()
dismiss()
}
.keyboardShortcut(.cancelAction)
.controlSize(.small)
Spacer()
Button("Restart Now") {
installing.retryTerminatingApplication()
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
.padding(16)
}
}
private struct NotFoundView: View {
let notFound: UpdateState.NotFound
let dismiss: DismissAction

View File

@@ -274,28 +274,19 @@ enum UpdateSimulator {
}
}
private func simulateInstalling(_ viewModel: UpdateViewModel) {
private func simulateInstalling(_ viewModel: UpdateViewModel, appcastItem: SUAppcastItem? = nil) {
viewModel.state = .installing(.init(
appcastItem: appcastItem,
retryTerminatingApplication: {
print("Restart button clicked in simulator - resetting to idle")
viewModel.state = .idle
},
dismiss: {
viewModel.state = .idle
}
))
}
private func simulateAutoUpdate(_ viewModel: UpdateViewModel) {
viewModel.state = .installing(.init(
isAutoUpdate: true,
retryTerminatingApplication: {
print("Restart button clicked in simulator - resetting to idle")
viewModel.state = .idle
},
dismiss: {
viewModel.state = .idle
}
))
let item = SUAppcastItem.empty()
item.setValue("x.x.x", forKey: "_displayVersionString")
simulateInstalling(viewModel, appcastItem: item)
}
}

View File

@@ -30,8 +30,8 @@ class UpdateViewModel: ObservableObject {
return "Downloading…"
case .extracting(let extracting):
return String(format: "Preparing: %.0f%%", extracting.progress * 100)
case .installing(let install):
return install.isAutoUpdate ? "Restart to Complete Update" : "Installing…"
case let .installing(install):
return install.appcastItem != nil ? "Restart to Complete Update" : "Installing…"
case .notFound:
return "No Updates Available"
case .error(let err):
@@ -93,7 +93,11 @@ class UpdateViewModel: ObservableObject {
case .extracting:
return "Extracting and preparing the update"
case let .installing(install):
return install.isAutoUpdate ? "Restart to Complete Update" : "Installing update and preparing to restart"
if let item = install.appcastItem {
return "The update is ready. Version: \(item.displayVersionString)"
} else {
return "Installing update and preparing to restart"
}
case .notFound:
return "You are running the latest version"
case .error:
@@ -185,8 +189,22 @@ enum UpdateState: Equatable {
case extracting(Extracting)
case installing(Installing)
/// True if we're installing an update triggered manually.
var shouldTerminateWithoutWarning: Bool {
if case .installing(let installing) = self {
return installing.appcastItem == nil
} else {
return false
}
}
var isHidden: Bool {
if case .idle = self { return true }
if case .installing(let installing) = self {
// Hide the update pill when installing is triggered by auto update.
// There will be an alert when users check the updates themselves.
return installing.appcastItem != nil
}
return false
}
@@ -239,10 +257,17 @@ enum UpdateState: Equatable {
/// Confirms or accepts the current update state.
/// - For available updates: begins installation
/// - For ready-to-install: proceeds with installation
func confirm() {
/// - For installing: suppress termination warnings and restart
mutating func confirm() {
switch self {
case .updateAvailable(let available):
available.reply(.install)
case .installing(let installing):
// Remove appcastItem so we can restart without any other alerts.
var suppressTerminationWarnings = installing
suppressTerminationWarnings.appcastItem = nil
self = .installing(suppressTerminationWarnings)
installing.retryTerminatingApplication()
default:
break
}
@@ -266,8 +291,8 @@ enum UpdateState: Equatable {
return lDown.progress == rDown.progress && lDown.expectedLength == rDown.expectedLength
case (.extracting(let lExt), .extracting(let rExt)):
return lExt.progress == rExt.progress
case (.installing(let lInstall), .installing(let rInstall)):
return lInstall.isAutoUpdate == rInstall.isAutoUpdate
case (.installing(let lhs), .installing(let rhs)):
return lhs.appcastItem?.displayVersionString == rhs.appcastItem?.displayVersionString
default:
return false
}
@@ -378,9 +403,14 @@ enum UpdateState: Equatable {
}
struct Installing {
/// True if this state is triggered by ``Ghostty/UpdateDriver/updater(_:willInstallUpdateOnQuit:immediateInstallationBlock:)``
var isAutoUpdate = false
/// Non-nil if this state is triggered by auto update
var appcastItem: SUAppcastItem?
let retryTerminatingApplication: () -> Void
let dismiss: () -> Void
var releaseNotes: ReleaseNotes? {
guard let appcastItem else { return nil }
let currentCommit = Bundle.main.infoDictionary?["GhosttyCommit"] as? String
return ReleaseNotes(displayVersionString: appcastItem.displayVersionString, currentCommit: currentCommit)
}
}
}

View File

@@ -25,11 +25,9 @@ struct UpdateStateTests {
}
@Test func testInstallingEquality() {
let state1: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {}))
let state2: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {}))
let state1: UpdateState = .installing(.init(retryTerminatingApplication: {}))
let state2: UpdateState = .installing(.init(retryTerminatingApplication: {}))
#expect(state1 == state2)
let state3: UpdateState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {}))
#expect(state3 != state2)
}
@Test func testPermissionRequestEquality() {
@@ -100,13 +98,28 @@ struct UpdateStateTests {
// MARK: - isHidden Tests
@Test func testIsIdleTrue() {
let state: UpdateState = .idle
#expect(state.isHidden == true)
@Test(
arguments: [
(UpdateState.idle, true),
(.installing(.init(appcastItem: .empty(), retryTerminatingApplication: {})), true),
(.checking(.init(cancel: {})), false),
(.installing(.init(retryTerminatingApplication: {})), false)
]
)
func testIsHidden(_ state: UpdateState, expected: Bool) {
#expect(state.isHidden == expected)
}
@Test func testIsIdleFalse() {
let state: UpdateState = .checking(.init(cancel: {}))
#expect(state.isHidden == false)
// MARK: - shouldTerminateWithoutWarning Tests
@Test(
arguments: [
(UpdateState.idle, false),
(.installing(.init(appcastItem: .empty(), retryTerminatingApplication: {})), false),
(.installing(.init(retryTerminatingApplication: {})), true)
]
)
func testShouldTerminateWithoutWarning(_ state: UpdateState, expected: Bool) {
#expect(state.shouldTerminateWithoutWarning == expected)
}
}

View File

@@ -52,9 +52,9 @@ struct UpdateViewModelTests {
@Test func testInstallingText() {
let viewModel = UpdateViewModel()
viewModel.state = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {}))
viewModel.state = .installing(.init(retryTerminatingApplication: {}))
#expect(viewModel.text == "Installing…")
viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {}))
viewModel.state = .installing(.init(appcastItem: .empty(), retryTerminatingApplication: {}))
#expect(viewModel.text == "Restart to Complete Update")
}