diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 3ac1394a..59de5c09 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -199,18 +199,23 @@ class UpdateController { self.stopAttemptUpdateMonitoring() } - checkForUpdates() + requestCheckForUpdates(presentation: .custom) } /// Check for updates (used by the menu item). @objc func checkForUpdates() { - UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))") - checkForUpdatesWhenReady(retries: readyRetryCount) + requestCheckForUpdates(presentation: .dialog) } - private func performCheckForUpdates() { + private func requestCheckForUpdates(presentation: UpdateUserInitiatedCheckPresentation) { + UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"), presentation=\(presentation == .dialog ? "dialog" : "custom"))") + checkForUpdatesWhenReady(retries: readyRetryCount, presentation: presentation) + } + + private func performCheckForUpdates(presentation: UpdateUserInitiatedCheckPresentation) { startUpdaterIfNeeded() ensureSparkleInstallationCache() + userDriver.prepareForUserInitiatedCheck(presentation: presentation) if viewModel.state == .idle { updater.checkForUpdates() return @@ -220,12 +225,13 @@ class UpdateController { viewModel.state.cancel() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in + self?.userDriver.prepareForUserInitiatedCheck(presentation: presentation) self?.updater.checkForUpdates() } } /// Check for updates once the updater is ready (used by UI tests). - func checkForUpdatesWhenReady(retries: Int = 10) { + func checkForUpdatesWhenReady(retries: Int = 10, presentation: UpdateUserInitiatedCheckPresentation = .dialog) { readyCheckWorkItem?.cancel() readyCheckWorkItem = nil startUpdaterIfNeeded() @@ -233,7 +239,7 @@ class UpdateController { let canCheck = updater.canCheckForUpdates UpdateLogStore.shared.append("checkForUpdatesWhenReady invoked (canCheck=\(canCheck))") if canCheck { - performCheckForUpdates() + performCheckForUpdates(presentation: presentation) return } if viewModel.state.isIdle { @@ -248,14 +254,14 @@ class UpdateController { code: 1, userInfo: [NSLocalizedDescriptionKey: "Updater is still starting. Try again in a moment."] ), - retry: { [weak self] in self?.checkForUpdates() }, + retry: { [weak self] in self?.requestCheckForUpdates(presentation: presentation) }, dismiss: { [weak self] in self?.viewModel.state = .idle } )) } return } let workItem = DispatchWorkItem { [weak self] in - self?.checkForUpdatesWhenReady(retries: retries - 1) + self?.checkForUpdatesWhenReady(retries: retries - 1, presentation: presentation) } readyCheckWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + readyRetryDelay, execute: workItem) diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index 5017009e..2a11d345 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -101,10 +101,13 @@ extension UpdateDriver: SPUUpdaterDelegate { } } - func updater(_ updater: SPUUpdater, userDidMake _: SPUUserUpdateChoice, forUpdate _: SUAppcastItem, state _: SPUUserUpdateState) { + func updater(_ updater: SPUUpdater, userDidMake choice: SPUUserUpdateChoice, forUpdate _: SUAppcastItem, state: SPUUserUpdateState) { DispatchQueue.main.async { [weak viewModel] in viewModel?.clearDetectedUpdate() } + if state.userInitiated, choice != .install { + finishUserInitiatedCheckPresentation() + } } func updaterWillRelaunchApplication(_ updater: SPUUpdater) { diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift index 289df890..ecaa021f 100644 --- a/Sources/Update/UpdateDriver.swift +++ b/Sources/Update/UpdateDriver.swift @@ -1,20 +1,35 @@ 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) { + 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 @@ -36,14 +51,36 @@ class UpdateDriver: NSObject, SPUUserDriver { } func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { - UpdateLogStore.shared.append("show user-initiated update check") - beginChecking(cancel: cancellation) + 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))) } @@ -58,6 +95,14 @@ class UpdateDriver: NSObject, SPUUserDriver { 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))) } @@ -65,6 +110,14 @@ class UpdateDriver: NSObject, SPUUserDriver { 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 @@ -85,6 +138,10 @@ class UpdateDriver: NSObject, SPUUserDriver { 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, @@ -93,6 +150,10 @@ class UpdateDriver: NSObject, SPUUserDriver { 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 } @@ -105,6 +166,10 @@ class UpdateDriver: NSObject, SPUUserDriver { 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 } @@ -117,21 +182,37 @@ class UpdateDriver: NSObject, SPUUserDriver { 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 @@ -142,16 +223,29 @@ class UpdateDriver: NSObject, SPUUserDriver { 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() { - // No-op; cmux never shows Sparkle dialogs. + 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 @@ -275,6 +369,13 @@ class UpdateDriver: NSObject, SPUUserDriver { 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: @@ -302,6 +403,45 @@ class UpdateDriver: NSObject, SPUUserDriver { } } + 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()