Improve update UI error details
This commit is contained in:
parent
17a3e2033f
commit
0441efc675
18 changed files with 915 additions and 82 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue