diff --git a/macos/Sources/Features/Update/UpdateDelegate.swift b/macos/Sources/Features/Update/UpdateDelegate.swift index 26242b49e..619540851 100644 --- a/macos/Sources/Features/Update/UpdateDelegate.swift +++ b/macos/Sources/Features/Update/UpdateDelegate.swift @@ -22,7 +22,13 @@ extension UpdateDriver: SPUUpdaterDelegate { /// When `auto-update = check`, Sparkle will call the corresponding /// 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)) + viewModel.state = .installing(.init( + isAutoUpdate: true, + retryTerminatingApplication: immediateInstallHandler, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + } + )) return true } diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 94b43a3f3..3beb4c9be 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -172,7 +172,12 @@ class UpdateDriver: NSObject, SPUUserDriver { } func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { - viewModel.state = .installing(.init(retryTerminatingApplication: retryTerminatingApplication)) + viewModel.state = .installing(.init( + retryTerminatingApplication: retryTerminatingApplication, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + } + )) if !hasUnobtrusiveTarget { standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication) diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 08d25a4d1..87d76f801 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -291,7 +291,15 @@ fileprivate struct InstallingView: View { } HStack { + Button("Restart Later") { + installing.dismiss() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + Spacer() + Button("Restart Now") { installing.retryTerminatingApplication() dismiss() diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift index cb4383a00..bf168d9fc 100644 --- a/macos/Sources/Features/Update/UpdateSimulator.swift +++ b/macos/Sources/Features/Update/UpdateSimulator.swift @@ -31,6 +31,9 @@ enum UpdateSimulator { /// Shows the installing state with restart button: installing (stays until dismissed) case installing + /// Simulates auto-update flow: goes directly to installing state without showing intermediate UI + case autoUpdate + func simulate(with viewModel: UpdateViewModel) { switch self { case .happyPath: @@ -49,6 +52,8 @@ enum UpdateSimulator { simulateCancelDuringChecking(viewModel) case .installing: simulateInstalling(viewModel) + case .autoUpdate: + simulateAutoUpdate(viewModel) } } @@ -270,9 +275,27 @@ enum UpdateSimulator { } private func simulateInstalling(_ viewModel: UpdateViewModel) { - viewModel.state = .installing(.init(retryTerminatingApplication: { - print("Restart button clicked in simulator - resetting to idle") - viewModel.state = .idle - })) + viewModel.state = .installing(.init( + 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 + } + )) } } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 96cbe7c3d..1f9304616 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -367,5 +367,6 @@ enum UpdateState: Equatable { /// True if this state is triggered by ``Ghostty/UpdateDriver/updater(_:willInstallUpdateOnQuit:immediateInstallationBlock:)`` var isAutoUpdate = false let retryTerminatingApplication: () -> Void + let dismiss: () -> Void } } diff --git a/macos/Tests/Update/UpdateStateTests.swift b/macos/Tests/Update/UpdateStateTests.swift index 1d1d7b37d..354d371c5 100644 --- a/macos/Tests/Update/UpdateStateTests.swift +++ b/macos/Tests/Update/UpdateStateTests.swift @@ -25,10 +25,10 @@ struct UpdateStateTests { } @Test func testInstallingEquality() { - let state1: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {})) - let state2: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {})) + let state1: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) + let state2: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) #expect(state1 == state2) - let state3: UpdateState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {})) + let state3: UpdateState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) #expect(state3 != state2) } diff --git a/macos/Tests/Update/UpdateViewModelTests.swift b/macos/Tests/Update/UpdateViewModelTests.swift index 5b223c59d..529c2bc52 100644 --- a/macos/Tests/Update/UpdateViewModelTests.swift +++ b/macos/Tests/Update/UpdateViewModelTests.swift @@ -52,9 +52,9 @@ struct UpdateViewModelTests { @Test func testInstallingText() { let viewModel = UpdateViewModel() - viewModel.state = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {})) + viewModel.state = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) #expect(viewModel.text == "Installing…") - viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {})) + viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) #expect(viewModel.text == "Restart to Complete Update") }