mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-01-02 19:42:38 +00:00
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.
110 lines
3.6 KiB
Swift
110 lines
3.6 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
|
|
}
|
|
|
|
/// True if we're installing an update.
|
|
var isInstalling: Bool {
|
|
installCancellable != nil
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|