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
parent
cbeb6890c9
commit
8f1a014afd
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue