mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-05 15:18:40 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user