cmux/Sources/Update/UpdateDriver.swift
2026-01-26 17:25:34 -08:00

181 lines
6.1 KiB
Swift

import Cocoa
import Sparkle
/// SPUUserDriver that updates the view model for custom update UI.
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()
}
func show(_ request: SPUUpdatePermissionRequest,
reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
viewModel.state = .permissionRequest(.init(request: request, reply: { [weak viewModel] response in
viewModel?.state = .idle
reply(response)
}))
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) {
// cmux uses Sparkle's UI for release notes links instead.
}
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) {
// Release notes are handled via link buttons.
}
func showUpdateNotFoundWithError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
viewModel.state = .notFound(.init(acknowledgement: acknowledgement))
if !hasUnobtrusiveTarget {
standard.showUpdateNotFoundWithError(error, acknowledgement: 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(nil)
}
},
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) {
if !hasUnobtrusiveTarget {
standard.showReady(toInstallAndRelaunch: reply)
} else {
reply(.install)
}
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
viewModel.state = .installing(.init(
retryTerminatingApplication: retryTerminatingApplication,
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}
))
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 { $0.isVisible }
}
}