Files
ghostty/macos/Sources/Features/Update/UpdateDriver.swift

206 lines
7.1 KiB
Swift

import Cocoa
import Sparkle
/// Implement the SPUUserDriver to modify our UpdateViewModel for custom presentation.
class UpdateDriver: NSObject, SPUUserDriver {
let viewModel: UpdateViewModel
let standard: SPUStandardUserDriver
init(viewModel: UpdateViewModel, hostBundle: Bundle) {
self.viewModel = viewModel
self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil)
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(handleTerminalWindowWillClose),
name: TerminalWindow.terminalWillCloseNotification,
object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc private func handleTerminalWindowWillClose() {
// If we lost the ability to show unobtrusive states, cancel whatever
// update state we're in. This will allow the manual `check for updates`
// call to initialize the standard driver.
//
// We have to do this after a short delay so that the window can fully
// close.
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
guard let self else { return }
guard !hasUnobtrusiveTarget else { return }
viewModel.state.cancel()
viewModel.state = .idle
}
}
func show(_ request: SPUUpdatePermissionRequest,
reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
viewModel.state = .permissionRequest(.init(request: request, reply: reply))
if !hasUnobtrusiveTarget {
standard.show(request, reply: reply)
}
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
viewModel.state = .checking(.init(cancel: cancellation))
if !hasUnobtrusiveTarget {
standard.showUserInitiatedUpdateCheck(cancellation: cancellation)
}
}
func showUpdateFound(with appcastItem: SUAppcastItem,
state: SPUUserUpdateState,
reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply))
if !hasUnobtrusiveTarget {
standard.showUpdateFound(with: appcastItem, state: state, reply: reply)
}
}
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
// We don't do anything with the release notes here because Ghostty
// doesn't use the release notes feature of Sparkle currently.
}
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) {
// We don't do anything with release notes. See `showUpdateReleaseNotes`
}
func showUpdateNotFoundWithError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
viewModel.state = .notFound
if !hasUnobtrusiveTarget {
standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement)
} else {
acknowledgement()
}
}
func showUpdaterError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
viewModel.state = .error(.init(
error: error,
retry: { [weak viewModel] in
viewModel?.state = .idle
DispatchQueue.main.async {
guard let delegate = NSApp.delegate as? AppDelegate else { return }
delegate.checkForUpdates(self)
}
},
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}))
if !hasUnobtrusiveTarget {
standard.showUpdaterError(error, acknowledgement: acknowledgement)
} else {
acknowledgement()
}
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {
viewModel.state = .downloading(.init(
cancel: cancellation,
expectedLength: nil,
progress: 0))
if !hasUnobtrusiveTarget {
standard.showDownloadInitiated(cancellation: cancellation)
}
}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
guard case let .downloading(downloading) = viewModel.state else {
return
}
viewModel.state = .downloading(.init(
cancel: downloading.cancel,
expectedLength: expectedContentLength,
progress: 0))
if !hasUnobtrusiveTarget {
standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength)
}
}
func showDownloadDidReceiveData(ofLength length: UInt64) {
guard case let .downloading(downloading) = viewModel.state else {
return
}
viewModel.state = .downloading(.init(
cancel: downloading.cancel,
expectedLength: downloading.expectedLength,
progress: downloading.progress + length))
if !hasUnobtrusiveTarget {
standard.showDownloadDidReceiveData(ofLength: length)
}
}
func showDownloadDidStartExtractingUpdate() {
viewModel.state = .extracting(.init(progress: 0))
if !hasUnobtrusiveTarget {
standard.showDownloadDidStartExtractingUpdate()
}
}
func showExtractionReceivedProgress(_ progress: Double) {
viewModel.state = .extracting(.init(progress: progress))
if !hasUnobtrusiveTarget {
standard.showExtractionReceivedProgress(progress)
}
}
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
viewModel.state = .readyToInstall(.init(reply: reply))
if !hasUnobtrusiveTarget {
standard.showReady(toInstallAndRelaunch: reply)
}
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
viewModel.state = .installing
if !hasUnobtrusiveTarget {
standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication)
}
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement)
viewModel.state = .idle
}
func showUpdateInFocus() {
if !hasUnobtrusiveTarget {
standard.showUpdateInFocus()
}
}
func dismissUpdateInstallation() {
viewModel.state = .idle
standard.dismissUpdateInstallation()
}
// MARK: No-Window Fallback
/// True if there is a target that can render our unobtrusive update checker.
var hasUnobtrusiveTarget: Bool {
NSApp.windows.contains { window in
window is TerminalWindow &&
window.isVisible
}
}
}