fix: show Sparkle dialog on first manual update check
This commit is contained in:
parent
f0dcdf90e9
commit
72f2e3b89d
3 changed files with 162 additions and 13 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue