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