cmux/Sources/Update/UpdateDriver.swift
2026-03-20 23:06:46 -07:00

452 lines
17 KiB
Swift

import Cocoa
import Sparkle
enum UpdateUserInitiatedCheckPresentation {
case dialog
case custom
}
/// SPUUserDriver that updates the view model for custom update UI.
class UpdateDriver: NSObject, SPUUserDriver {
let viewModel: UpdateViewModel
private let standard: SPUStandardUserDriver
private let minimumCheckDuration: TimeInterval = UpdateTiming.minimumCheckDisplayDuration
private var lastCheckStart: Date?
private var pendingCheckTransition: DispatchWorkItem?
private var checkTimeoutWorkItem: DispatchWorkItem?
private var lastFeedURLString: String?
private var pendingUserInitiatedCheckPresentation: UpdateUserInitiatedCheckPresentation?
private var activeUserInitiatedCheckPresentation: UpdateUserInitiatedCheckPresentation?
init(viewModel: UpdateViewModel, hostBundle: Bundle) {
self.viewModel = viewModel
self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil)
super.init()
}
func prepareForUserInitiatedCheck(presentation: UpdateUserInitiatedCheckPresentation) {
runOnMain { [weak self] in
self?.pendingUserInitiatedCheckPresentation = presentation
}
}
func show(_ request: SPUUpdatePermissionRequest,
reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
#if DEBUG
let env = ProcessInfo.processInfo.environment
if env["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" || env["CMUX_UI_TEST_AUTO_ALLOW_PERMISSION"] == "1" {
UpdateLogStore.shared.append("auto-allow update permission (ui test)")
DispatchQueue.main.async {
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
}
return
}
#endif
// Never show Sparkle's permission UI. cmux always enables scheduled checks and keeps
// automatic downloads disabled so installs remain user-driven.
UpdateLogStore.shared.append("auto-allow update permission (no UI)")
DispatchQueue.main.async {
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
}
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
let presentation = activateUserInitiatedCheckPresentation()
UpdateLogStore.shared.append("show user-initiated update check (\(describe(presentation)))")
let cancel = { [weak self] in
self?.finishUserInitiatedCheckPresentation()
cancellation()
}
switch presentation {
case .dialog:
clearCustomStateForStandardPresentation()
standard.showUserInitiatedUpdateCheck(cancellation: cancel)
case .custom:
beginChecking(cancel: cancel)
}
}
func showUpdateFound(with appcastItem: SUAppcastItem,
state: SPUUserUpdateState,
reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
UpdateLogStore.shared.append("show update found: \(appcastItem.displayVersionString)")
if usesStandardPresentation {
clearCustomStateForStandardPresentation()
standard.showUpdateFound(with: appcastItem, state: state) { [weak self] choice in
if choice != .install {
self?.finishUserInitiatedCheckPresentation()
}
reply(choice)
}
return
}
setStateAfterMinimumCheckDelay(.updateAvailable(.init(appcastItem: appcastItem, 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) {
UpdateLogStore.shared.append("show update not found: \(formatErrorForLog(error))")
if usesStandardPresentation {
clearCustomStateForStandardPresentation()
standard.showUpdateNotFoundWithError(error) { [weak self] in
self?.finishUserInitiatedCheckPresentation()
acknowledgement()
}
return
}
setStateAfterMinimumCheckDelay(.notFound(.init(acknowledgement: acknowledgement)))
}
func showUpdaterError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
let details = formatErrorForLog(error)
UpdateLogStore.shared.append("show updater error: \(details)")
if usesStandardPresentation {
clearCustomStateForStandardPresentation()
standard.showUpdaterError(error) { [weak self] in
self?.finishUserInitiatedCheckPresentation()
acknowledgement()
}
return
}
setState(.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
},
technicalDetails: details,
feedURLString: lastFeedURLString
)))
acknowledgement()
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {
UpdateLogStore.shared.append("show download initiated")
if usesStandardPresentation {
standard.showDownloadInitiated(cancellation: cancellation)
return
}
setState(.downloading(.init(
cancel: cancellation,
expectedLength: nil,
progress: 0)))
}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
UpdateLogStore.shared.append("download expected length: \(expectedContentLength)")
if usesStandardPresentation {
standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength)
return
}
guard case let .downloading(downloading) = viewModel.state else {
return
}
setState(.downloading(.init(
cancel: downloading.cancel,
expectedLength: expectedContentLength,
progress: 0)))
}
func showDownloadDidReceiveData(ofLength length: UInt64) {
UpdateLogStore.shared.append("download received data: \(length)")
if usesStandardPresentation {
standard.showDownloadDidReceiveData(ofLength: length)
return
}
guard case let .downloading(downloading) = viewModel.state else {
return
}
setState(.downloading(.init(
cancel: downloading.cancel,
expectedLength: downloading.expectedLength,
progress: downloading.progress + length)))
}
func showDownloadDidStartExtractingUpdate() {
UpdateLogStore.shared.append("show extraction started")
if usesStandardPresentation {
standard.showDownloadDidStartExtractingUpdate()
return
}
setState(.extracting(.init(progress: 0)))
}
func showExtractionReceivedProgress(_ progress: Double) {
UpdateLogStore.shared.append(String(format: "show extraction progress: %.2f", progress))
if usesStandardPresentation {
standard.showExtractionReceivedProgress(progress)
return
}
setState(.extracting(.init(progress: progress)))
}
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
UpdateLogStore.shared.append("show ready to install")
if usesStandardPresentation {
standard.showReady(toInstallAndRelaunch: reply)
return
}
reply(.install)
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
UpdateLogStore.shared.append("show installing update")
if usesStandardPresentation {
standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication)
return
}
setState(.installing(.init(
retryTerminatingApplication: retryTerminatingApplication,
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}
)))
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
UpdateLogStore.shared.append("show update installed (relaunched=\(relaunched))")
if usesStandardPresentation {
standard.showUpdateInstalledAndRelaunched(relaunched) { [weak self] in
self?.finishUserInitiatedCheckPresentation()
acknowledgement()
}
return
}
setState(.idle)
acknowledgement()
}
func showUpdateInFocus() {
if usesStandardPresentation {
standard.showUpdateInFocus()
}
}
func dismissUpdateInstallation() {
UpdateLogStore.shared.append("dismiss update installation")
if usesStandardPresentation {
standard.dismissUpdateInstallation()
return
}
if case .error = viewModel.state {
UpdateLogStore.shared.append("dismiss update installation ignored (error visible)")
return
}
if case .notFound = viewModel.state {
UpdateLogStore.shared.append("dismiss update installation ignored (notFound visible)")
return
}
if case .checking = viewModel.state {
UpdateLogStore.shared.append("dismiss update installation ignored (checking)")
return
}
setState(.idle)
}
private func beginChecking(cancel: @escaping () -> Void) {
runOnMain { [weak self] in
guard let self else { return }
viewModel.overrideState = nil
pendingCheckTransition?.cancel()
pendingCheckTransition = nil
checkTimeoutWorkItem?.cancel()
checkTimeoutWorkItem = nil
lastCheckStart = Date()
applyState(.checking(.init(cancel: cancel)))
scheduleCheckTimeout()
}
}
private func setStateAfterMinimumCheckDelay(_ newState: UpdateState) {
runOnMain { [weak self] in
guard let self else { return }
pendingCheckTransition?.cancel()
pendingCheckTransition = nil
checkTimeoutWorkItem?.cancel()
checkTimeoutWorkItem = nil
guard let start = lastCheckStart else {
lastCheckStart = nil
applyState(newState)
return
}
let elapsed = Date().timeIntervalSince(start)
if elapsed >= minimumCheckDuration {
lastCheckStart = nil
applyState(newState)
return
}
let delay = minimumCheckDuration - elapsed
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
guard case .checking = self.viewModel.state else { return }
self.lastCheckStart = nil
self.applyState(newState)
}
pendingCheckTransition = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
}
private func setState(_ newState: UpdateState) {
runOnMain { [weak self] in
guard let self else { return }
pendingCheckTransition?.cancel()
pendingCheckTransition = nil
checkTimeoutWorkItem?.cancel()
checkTimeoutWorkItem = nil
lastCheckStart = nil
applyState(newState)
}
}
private func scheduleCheckTimeout() {
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
guard case .checking = self.viewModel.state else { return }
self.setState(.notFound(.init(acknowledgement: {})))
}
checkTimeoutWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + UpdateTiming.checkTimeoutDuration, execute: workItem)
}
private func applyState(_ newState: UpdateState) {
viewModel.state = newState
UpdateLogStore.shared.append("state -> \(describe(newState))")
}
func resolvedFeedURLString() -> String? {
lastFeedURLString
}
func recordFeedURLString(_ feedURLString: String, usedFallback: Bool) {
if lastFeedURLString == feedURLString {
return
}
lastFeedURLString = feedURLString
let suffix = usedFallback ? " (fallback)" : ""
UpdateLogStore.shared.append("feed url resolved\(suffix): \(feedURLString)")
}
func formatErrorForLog(_ error: Error) -> String {
let nsError = error as NSError
var parts: [String] = ["\(nsError.domain)(\(nsError.code))"]
if !nsError.localizedDescription.isEmpty {
parts.append(nsError.localizedDescription)
}
if let url = nsError.userInfo[NSURLErrorFailingURLErrorKey] as? URL {
parts.append("url=\(url.absoluteString)")
} else if let urlString = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String {
parts.append("url=\(urlString)")
}
if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError {
let detail = "\(underlying.domain)(\(underlying.code)) \(underlying.localizedDescription)"
parts.append("underlying=\(detail)")
}
if let feed = lastFeedURLString {
parts.append("feed=\(feed)")
}
return parts.joined(separator: " | ")
}
func finishUserInitiatedCheckPresentation() {
runOnMain { [weak self] in
self?.pendingUserInitiatedCheckPresentation = nil
self?.activeUserInitiatedCheckPresentation = nil
}
}
private func describe(_ state: UpdateState) -> String {
switch state {
case .idle:
return "idle"
case .permissionRequest:
return "permissionRequest"
case .checking:
return "checking"
case .updateAvailable(let update):
return "updateAvailable(\(update.appcastItem.displayVersionString))"
case .notFound:
return "notFound"
case .error(let err):
return "error(\(err.error.localizedDescription))"
case .downloading(let download):
if let expected = download.expectedLength, expected > 0 {
let percent = Double(download.progress) / Double(expected) * 100
return String(format: "downloading(%.0f%%)", percent)
}
return "downloading"
case .extracting(let extracting):
return String(format: "extracting(%.0f%%)", extracting.progress * 100)
case .installing(let installing):
return "installing(auto=\(installing.isAutoUpdate))"
}
}
private func describe(_ presentation: UpdateUserInitiatedCheckPresentation) -> String {
switch presentation {
case .dialog:
return "dialog"
case .custom:
return "custom"
}
}
private var usesStandardPresentation: Bool {
currentUserInitiatedCheckPresentation() == .dialog
}
private func currentUserInitiatedCheckPresentation() -> UpdateUserInitiatedCheckPresentation? {
activeUserInitiatedCheckPresentation ?? pendingUserInitiatedCheckPresentation
}
private func activateUserInitiatedCheckPresentation() -> UpdateUserInitiatedCheckPresentation {
let presentation = currentUserInitiatedCheckPresentation() ?? .dialog
activeUserInitiatedCheckPresentation = presentation
pendingUserInitiatedCheckPresentation = nil
return presentation
}
private func clearCustomStateForStandardPresentation() {
runOnMain { [weak self] in
guard let self else { return }
pendingCheckTransition?.cancel()
pendingCheckTransition = nil
checkTimeoutWorkItem?.cancel()
checkTimeoutWorkItem = nil
lastCheckStart = nil
if case .idle = viewModel.state {
return
}
applyState(.idle)
}
}
private func runOnMain(_ action: @escaping () -> Void) {
if Thread.isMainThread {
action()
} else {
DispatchQueue.main.async(execute: action)
}
}
}