Improve update UI error details

This commit is contained in:
Lawrence Chen 2026-01-28 01:49:02 -08:00
parent 17a3e2033f
commit 0441efc675
18 changed files with 915 additions and 82 deletions

View file

@ -5,6 +5,11 @@ import Sparkle
class UpdateDriver: NSObject, SPUUserDriver {
let viewModel: UpdateViewModel
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?
init(viewModel: UpdateViewModel, hostBundle: Bundle) {
self.viewModel = viewModel
@ -14,17 +19,19 @@ class UpdateDriver: NSObject, SPUUserDriver {
func show(_ request: SPUUpdatePermissionRequest,
reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
viewModel.state = .permissionRequest(.init(request: request, reply: { [weak viewModel] response in
UpdateLogStore.shared.append("show update permission request")
setState(.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))
UpdateLogStore.shared.append("show user-initiated update check")
beginChecking(cancel: cancellation)
if !hasUnobtrusiveTarget {
standard.showUserInitiatedUpdateCheck(cancellation: cancellation)
}
@ -33,7 +40,8 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showUpdateFound(with appcastItem: SUAppcastItem,
state: SPUUserUpdateState,
reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply))
UpdateLogStore.shared.append("show update found: \(appcastItem.displayVersionString)")
setStateAfterMinimumCheckDelay(.updateAvailable(.init(appcastItem: appcastItem, reply: reply)))
if !hasUnobtrusiveTarget {
standard.showUpdateFound(with: appcastItem, state: state, reply: reply)
}
@ -49,7 +57,8 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showUpdateNotFoundWithError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
viewModel.state = .notFound(.init(acknowledgement: acknowledgement))
UpdateLogStore.shared.append("show update not found: \(formatErrorForLog(error))")
setStateAfterMinimumCheckDelay(.notFound(.init(acknowledgement: acknowledgement)))
if !hasUnobtrusiveTarget {
standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement)
@ -58,7 +67,9 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showUpdaterError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
viewModel.state = .error(.init(
let details = formatErrorForLog(error)
UpdateLogStore.shared.append("show updater error: \(details)")
setState(.error(.init(
error: error,
retry: { [weak viewModel] in
viewModel?.state = .idle
@ -69,7 +80,10 @@ class UpdateDriver: NSObject, SPUUserDriver {
},
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}))
},
technicalDetails: details,
feedURLString: lastFeedURLString
)))
if !hasUnobtrusiveTarget {
standard.showUpdaterError(error, acknowledgement: acknowledgement)
@ -79,10 +93,11 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {
viewModel.state = .downloading(.init(
UpdateLogStore.shared.append("show download initiated")
setState(.downloading(.init(
cancel: cancellation,
expectedLength: nil,
progress: 0))
progress: 0)))
if !hasUnobtrusiveTarget {
standard.showDownloadInitiated(cancellation: cancellation)
@ -90,14 +105,15 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
UpdateLogStore.shared.append("download expected length: \(expectedContentLength)")
guard case let .downloading(downloading) = viewModel.state else {
return
}
viewModel.state = .downloading(.init(
setState(.downloading(.init(
cancel: downloading.cancel,
expectedLength: expectedContentLength,
progress: 0))
progress: 0)))
if !hasUnobtrusiveTarget {
standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength)
@ -105,14 +121,15 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showDownloadDidReceiveData(ofLength length: UInt64) {
UpdateLogStore.shared.append("download received data: \(length)")
guard case let .downloading(downloading) = viewModel.state else {
return
}
viewModel.state = .downloading(.init(
setState(.downloading(.init(
cancel: downloading.cancel,
expectedLength: downloading.expectedLength,
progress: downloading.progress + length))
progress: downloading.progress + length)))
if !hasUnobtrusiveTarget {
standard.showDownloadDidReceiveData(ofLength: length)
@ -120,7 +137,8 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showDownloadDidStartExtractingUpdate() {
viewModel.state = .extracting(.init(progress: 0))
UpdateLogStore.shared.append("show extraction started")
setState(.extracting(.init(progress: 0)))
if !hasUnobtrusiveTarget {
standard.showDownloadDidStartExtractingUpdate()
@ -128,7 +146,8 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showExtractionReceivedProgress(_ progress: Double) {
viewModel.state = .extracting(.init(progress: progress))
UpdateLogStore.shared.append(String(format: "show extraction progress: %.2f", progress))
setState(.extracting(.init(progress: progress)))
if !hasUnobtrusiveTarget {
standard.showExtractionReceivedProgress(progress)
@ -136,6 +155,7 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
UpdateLogStore.shared.append("show ready to install")
if !hasUnobtrusiveTarget {
standard.showReady(toInstallAndRelaunch: reply)
} else {
@ -144,12 +164,13 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
viewModel.state = .installing(.init(
UpdateLogStore.shared.append("show installing update")
setState(.installing(.init(
retryTerminatingApplication: retryTerminatingApplication,
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}
))
)))
if !hasUnobtrusiveTarget {
standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication)
@ -157,8 +178,9 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
UpdateLogStore.shared.append("show update installed (relaunched=\(relaunched))")
standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement)
viewModel.state = .idle
setState(.idle)
}
func showUpdateInFocus() {
@ -168,14 +190,163 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func dismissUpdateInstallation() {
viewModel.state = .idle
UpdateLogStore.shared.append("dismiss update installation")
if case .error = viewModel.state {
UpdateLogStore.shared.append("dismiss update installation ignored (error visible)")
standard.dismissUpdateInstallation()
return
}
setState(.idle)
standard.dismissUpdateInstallation()
}
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: " | ")
}
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))"
}
}
// 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 }
}
private func runOnMain(_ action: @escaping () -> Void) {
if Thread.isMainThread {
action()
} else {
DispatchQueue.main.async(execute: action)
}
}
}