macos: clean up the "installing" update state (#9170)

This includes multiple changes to clean up the "installing" state:

- Ghostty will not confirm quit, since the user has already confirmed
they want to restart to install the update.
- If termination fails for any reason, the popover has a button to retry
restarting.
- The copy and badge symbol have been updated to better match the
reality of the "installing" state.

<img width="1756" height="890" alt="CleanShot 2025-10-12 at 15 04 08@2x"
src="https://github.com/user-attachments/assets/1b769518-e15f-4758-be3b-c45163fa2603"
/>

AI written:
https://ampcode.com/threads/T-623d1030-419f-413f-a285-e79c86a4246b fully
understood.
pull/9171/head
Mitchell Hashimoto 2025-10-12 15:20:26 -07:00 committed by GitHub
parent cbeb6890c9
commit 8f1a014afd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 62 additions and 22 deletions

View File

@ -322,6 +322,12 @@ class AppDelegate: NSObject,
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
let windows = NSApplication.shared.windows
if (windows.isEmpty) { return .terminateNow }
// 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 {
return .terminateNow
}
// This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't
// quite work with SwiftUI because windows are retained on close. So instead we check

View File

@ -32,7 +32,7 @@ struct UpdateBadge: View {
case .extracting(let extracting):
ProgressRingView(progress: min(1, max(0, extracting.progress)))
case .checking, .installing:
case .checking:
if let iconName = model.iconName {
Image(systemName: iconName)
.rotationEffect(.degrees(rotationAngle))

View File

@ -17,6 +17,11 @@ class UpdateController {
userDriver.viewModel
}
/// True if we're installing an update.
var isInstalling: Bool {
installCancellable != nil
}
/// Initialize a new update controller.
init() {
let hostBundle = Bundle.main

View File

@ -172,7 +172,7 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
viewModel.state = .installing
viewModel.state = .installing(.init(retryTerminatingApplication: retryTerminatingApplication))
if !hasUnobtrusiveTarget {
standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication)

View File

@ -38,8 +38,8 @@ struct UpdatePopoverView: View {
case .readyToInstall(let ready):
ReadyToInstallView(ready: ready, dismiss: dismiss)
case .installing:
InstallingView()
case .installing(let installing):
InstallingView(installing: installing, dismiss: dismiss)
case .notFound(let notFound):
NotFoundView(notFound: notFound, dismiss: dismiss)
@ -313,18 +313,31 @@ fileprivate struct ReadyToInstallView: View {
}
fileprivate struct InstallingView: View {
let installing: UpdateState.Installing
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Installing…")
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)
}
Text("The application will relaunch shortly.")
.font(.system(size: 11))
.foregroundColor(.secondary)
HStack {
Spacer()
Button("Restart Now") {
installing.retryTerminatingApplication()
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
.padding(16)
}

View File

@ -28,6 +28,9 @@ enum UpdateSimulator {
/// User cancels while checking: checking (1s) cancels idle
case cancelDuringChecking
/// Shows the installing state with restart button: installing (stays until dismissed)
case installing
func simulate(with viewModel: UpdateViewModel) {
switch self {
case .happyPath:
@ -44,6 +47,8 @@ enum UpdateSimulator {
simulateCancelDuringDownload(viewModel)
case .cancelDuringChecking:
simulateCancelDuringChecking(viewModel)
case .installing:
simulateInstalling(viewModel)
}
}
@ -260,10 +265,10 @@ enum UpdateSimulator {
viewModel.state = .readyToInstall(.init(
reply: { choice in
if choice == .install {
viewModel.state = .installing
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
viewModel.state = .installing(.init(retryTerminatingApplication: {
print("Restart button clicked in simulator - resetting to idle")
viewModel.state = .idle
}
}))
} else {
viewModel.state = .idle
}
@ -274,4 +279,11 @@ enum UpdateSimulator {
}
}
}
private func simulateInstalling(_ viewModel: UpdateViewModel) {
viewModel.state = .installing(.init(retryTerminatingApplication: {
print("Restart button clicked in simulator - resetting to idle")
viewModel.state = .idle
}))
}
}

View File

@ -33,7 +33,7 @@ class UpdateViewModel: ObservableObject {
case .readyToInstall:
return "Install Update"
case .installing:
return "Installing…"
return "Restart to Complete Update"
case .notFound:
return "No Updates Available"
case .error(let err):
@ -72,7 +72,7 @@ class UpdateViewModel: ObservableObject {
case .readyToInstall:
return "checkmark.circle.fill"
case .installing:
return "gear"
return "power.circle"
case .notFound:
return "info.circle"
case .error:
@ -192,7 +192,7 @@ enum UpdateState: Equatable {
case downloading(Downloading)
case extracting(Extracting)
case readyToInstall(ReadyToInstall)
case installing
case installing(Installing)
var isIdle: Bool {
if case .idle = self { return true }
@ -382,4 +382,8 @@ enum UpdateState: Equatable {
struct ReadyToInstall {
let reply: @Sendable (SPUUserUpdateChoice) -> Void
}
struct Installing {
let retryTerminatingApplication: () -> Void
}
}

View File

@ -25,8 +25,8 @@ struct UpdateStateTests {
}
@Test func testInstallingEquality() {
let state1: UpdateState = .installing
let state2: UpdateState = .installing
let state1: UpdateState = .installing(.init(retryTerminatingApplication: {}))
let state2: UpdateState = .installing(.init(retryTerminatingApplication: {}))
#expect(state1 == state2)
}

View File

@ -58,8 +58,8 @@ struct UpdateViewModelTests {
@Test func testInstallingText() {
let viewModel = UpdateViewModel()
viewModel.state = .installing
#expect(viewModel.text == "Installing…")
viewModel.state = .installing(.init(retryTerminatingApplication: {}))
#expect(viewModel.text == "Restart to Complete Update")
}
@Test func testNotFoundText() {