Files
ghostty/macos/Sources/Features/Update/UpdateController.swift
Mitchell Hashimoto ac2f040b31 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
2025-10-10 13:40:35 -07:00

105 lines
3.4 KiB
Swift

import Sparkle
import Cocoa
import Combine
/// Standard controller for managing Sparkle updates in Ghostty.
///
/// This controller wraps SPUStandardUpdaterController to provide a simpler interface
/// for managing updates with Ghostty's custom driver and delegate. It handles
/// initialization, starting the updater, and provides the check for updates action.
class UpdateController {
private(set) var updater: SPUUpdater
private let userDriver: UpdateDriver
private let updaterDelegate = UpdaterDelegate()
private var installCancellable: AnyCancellable?
var viewModel: UpdateViewModel {
userDriver.viewModel
}
/// Initialize a new update controller.
init() {
let hostBundle = Bundle.main
self.userDriver = UpdateDriver(
viewModel: .init(),
hostBundle: hostBundle)
self.updater = SPUUpdater(
hostBundle: hostBundle,
applicationBundle: hostBundle,
userDriver: userDriver,
delegate: updaterDelegate
)
}
deinit {
installCancellable?.cancel()
}
/// Start the updater.
///
/// This must be called before the updater can check for updates. If starting fails,
/// the error will be shown to the user.
func startUpdater() {
do {
try updater.start()
} catch {
userDriver.viewModel.state = .error(.init(
error: error,
retry: { [weak self] in
self?.userDriver.viewModel.state = .idle
self?.startUpdater()
},
dismiss: { [weak self] in
self?.userDriver.viewModel.state = .idle
}
))
}
}
/// 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.
@objc func checkForUpdates() {
updater.checkForUpdates()
}
/// Validate the check for updates menu item.
///
/// - Parameter item: The menu item to validate
/// - Returns: Whether the menu item should be enabled
func validateMenuItem(_ item: NSMenuItem) -> Bool {
if item.action == #selector(checkForUpdates) {
return updater.canCheckForUpdates
}
return true
}
}