macos: Show "Update and Restart" in the Command Palette (#9131)

If an update is available, you can now trigger the full download,
install, and restart from a single command palette action. This allows
for a fully keyboard-driven update process.

While an update is being installed, an option to cancel or skip the
current update is also shown as an option, so that can also be
keyboard-driven.

This currently can't be bound to a keyboard action, but that may be
added in the future if there's demand for it.

**AI Disclosure:** Amp was used considerably. I reviewed all the code
and understand it.

## Demo



https://github.com/user-attachments/assets/df6307f8-9967-40d4-9a62-04feddf00ac2
This commit is contained in:
Mitchell Hashimoto
2025-10-10 13:40:35 -07:00
committed by GitHub
parent cd7621167f
commit ac2f040b31
5 changed files with 216 additions and 11 deletions

View File

@@ -1,5 +1,6 @@
import Sparkle
import Cocoa
import Combine
/// Standard controller for managing Sparkle updates in Ghostty.
///
@@ -10,6 +11,7 @@ class UpdateController {
private(set) var updater: SPUUpdater
private let userDriver: UpdateDriver
private let updaterDelegate = UpdaterDelegate()
private var installCancellable: AnyCancellable?
var viewModel: UpdateViewModel {
userDriver.viewModel
@@ -29,6 +31,10 @@ class UpdateController {
)
}
deinit {
installCancellable?.cancel()
}
/// Start the updater.
///
/// This must be called before the updater can check for updates. If starting fails,
@@ -50,6 +56,34 @@ class UpdateController {
}
}
/// Force install the current update. As long as we're in some "update available" state this will
/// trigger all the steps necessary to complete the update.
func installUpdate() {
// Must be in an installable state
guard viewModel.state.isInstallable else { return }
// If we're already force installing then do nothing.
guard installCancellable == nil else { return }
// Setup a combine listener to listen for state changes and to always
// confirm them. If we go to a non-installable state, cancel the listener.
// The sink runs immediately with the current state, so we don't need to
// manually confirm the first state.
installCancellable = viewModel.$state.sink { [weak self] state in
guard let self else { return }
// If we move to a non-installable state (error, idle, etc.) then we
// stop force installing.
guard state.isInstallable else {
self.installCancellable = nil
return
}
// Continue the `yes` chain!
state.confirm()
}
}
/// Check for updates.
///
/// This is typically connected to a menu item action.