fix: show Sparkle dialog on first manual update check

This commit is contained in:
austinpower1258 2026-03-20 23:06:46 -07:00
parent f0dcdf90e9
commit 72f2e3b89d
3 changed files with 162 additions and 13 deletions

View file

@ -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)

View file

@ -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) {

View file

@ -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()