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,9 +5,14 @@ import Sparkle
class UpdateViewModel: ObservableObject {
@Published var state: UpdateState = .idle
@Published var overrideState: UpdateState?
var effectiveState: UpdateState {
overrideState ?? state
}
var text: String {
switch state {
switch effectiveState {
case .idle:
return ""
case .permissionRequest:
@ -33,12 +38,12 @@ class UpdateViewModel: ObservableObject {
case .notFound:
return "No Updates Available"
case .error(let err):
return err.error.localizedDescription
return Self.userFacingErrorTitle(for: err.error)
}
}
var maxWidthText: String {
switch state {
switch effectiveState {
case .downloading:
return "Downloading: 100%"
case .extracting:
@ -49,7 +54,7 @@ class UpdateViewModel: ObservableObject {
}
var iconName: String? {
switch state {
switch effectiveState {
case .idle:
return nil
case .permissionRequest:
@ -72,7 +77,7 @@ class UpdateViewModel: ObservableObject {
}
var description: String {
switch state {
switch effectiveState {
case .idle:
return ""
case .permissionRequest:
@ -89,13 +94,13 @@ class UpdateViewModel: ObservableObject {
return install.isAutoUpdate ? "Restart to Complete Update" : "Installing update and preparing to restart"
case .notFound:
return "You are running the latest version"
case .error:
return "An error occurred during the update process"
case .error(let err):
return Self.userFacingErrorMessage(for: err.error)
}
}
var badge: String? {
switch state {
switch effectiveState {
case .updateAvailable(let update):
let version = update.appcastItem.displayVersionString
return version.isEmpty ? nil : version
@ -113,7 +118,7 @@ class UpdateViewModel: ObservableObject {
}
var iconColor: Color {
switch state {
switch effectiveState {
case .idle:
return .secondary
case .permissionRequest:
@ -132,7 +137,7 @@ class UpdateViewModel: ObservableObject {
}
var backgroundColor: Color {
switch state {
switch effectiveState {
case .permissionRequest:
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue)
case .updateAvailable:
@ -147,7 +152,7 @@ class UpdateViewModel: ObservableObject {
}
var foregroundColor: Color {
switch state {
switch effectiveState {
case .permissionRequest:
return .white
case .updateAvailable:
@ -160,6 +165,165 @@ class UpdateViewModel: ObservableObject {
return .primary
}
}
static func userFacingErrorTitle(for error: Swift.Error) -> String {
let nsError = error as NSError
if let networkError = networkError(from: nsError) {
switch networkError.code {
case NSURLErrorNotConnectedToInternet:
return "No Internet Connection"
case NSURLErrorTimedOut:
return "Update Timed Out"
case NSURLErrorCannotFindHost:
return "Server Not Found"
case NSURLErrorCannotConnectToHost:
return "Server Unreachable"
case NSURLErrorNetworkConnectionLost:
return "Connection Lost"
case NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid:
return "Secure Connection Failed"
default:
break
}
}
if nsError.domain == SUSparkleErrorDomain {
switch nsError.code {
case 2001:
return "Couldn't Download Update"
case 1000, 1002:
return "Update Feed Error"
case 4:
return "Invalid Update Feed"
case 3:
return "Insecure Update Feed"
case 1, 2, 3001, 3002:
return "Update Signature Error"
case 1003, 1005:
return "App Location Issue"
default:
break
}
}
return "Update Failed"
}
static func userFacingErrorMessage(for error: Swift.Error) -> String {
let nsError = error as NSError
if let networkError = networkError(from: nsError) {
switch networkError.code {
case NSURLErrorNotConnectedToInternet:
return "cmux cant reach the update server. Check your internet connection and try again."
case NSURLErrorTimedOut:
return "The update server took too long to respond. Try again in a moment."
case NSURLErrorCannotFindHost:
return "The update server cant be found. Check your connection or try again later."
case NSURLErrorCannotConnectToHost:
return "cmux couldnt connect to the update server. Check your connection or try again later."
case NSURLErrorNetworkConnectionLost:
return "The network connection was lost while checking for updates. Try again."
case NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid:
return "A secure connection to the update server couldnt be established. Try again later."
default:
break
}
}
if nsError.domain == SUSparkleErrorDomain {
switch nsError.code {
case 2001:
return "cmux couldnt download the update feed. Check your connection and try again."
case 1000, 1002:
return "The update feed could not be read. Please try again later."
case 4:
return "The update feed URL is invalid. Please contact support."
case 3:
return "The update feed is insecure. Please contact support."
case 1, 2, 3001, 3002:
return "The updates signature could not be verified. Please try again later."
case 1003, 1005:
return "Move cmux into Applications and relaunch to enable updates."
default:
break
}
}
return nsError.localizedDescription
}
static func errorDetails(for error: Swift.Error, technicalDetails: String?, feedURLString: String?) -> String {
let nsError = error as NSError
var lines: [String] = []
lines.append("Message: \(nsError.localizedDescription)")
lines.append("Domain: \(nsError.domain)")
if nsError.domain == SUSparkleErrorDomain,
let sparkleName = sparkleErrorCodeName(for: nsError.code) {
lines.append("Code: \(sparkleName) (\(nsError.code))")
} else {
lines.append("Code: \(nsError.code)")
}
if let url = nsError.userInfo[NSURLErrorFailingURLErrorKey] as? URL {
lines.append("URL: \(url.absoluteString)")
} else if let urlString = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String {
lines.append("URL: \(urlString)")
}
if let failure = nsError.userInfo[NSLocalizedFailureReasonErrorKey] as? String,
!failure.isEmpty {
lines.append("Failure: \(failure)")
}
if let recovery = nsError.userInfo[NSLocalizedRecoverySuggestionErrorKey] as? String,
!recovery.isEmpty {
lines.append("Recovery: \(recovery)")
}
if let feedURLString, !feedURLString.isEmpty {
lines.append("Feed: \(feedURLString)")
}
if let technicalDetails, !technicalDetails.isEmpty {
lines.append("Debug: \(technicalDetails)")
}
lines.append("Log: \(UpdateLogStore.shared.logPath())")
return lines.joined(separator: "\n")
}
private static func networkError(from error: NSError) -> NSError? {
if error.domain == NSURLErrorDomain {
return error
}
if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError,
underlying.domain == NSURLErrorDomain {
return underlying
}
return nil
}
private static func sparkleErrorCodeName(for code: Int) -> String? {
switch code {
case 1: return "SUNoPublicDSAFoundError"
case 2: return "SUInsufficientSigningError"
case 3: return "SUInsecureFeedURLError"
case 4: return "SUInvalidFeedURLError"
case 1000: return "SUAppcastParseError"
case 1001: return "SUNoUpdateError"
case 1002: return "SUAppcastError"
case 1003: return "SURunningFromDiskImageError"
case 1005: return "SURunningTranslocated"
case 2001: return "SUDownloadError"
case 3001: return "SUSignatureError"
case 3002: return "SUValidationError"
default:
return nil
}
}
}
enum UpdateState: Equatable {
@ -325,6 +489,20 @@ enum UpdateState: Equatable {
let error: any Swift.Error
let retry: () -> Void
let dismiss: () -> Void
let technicalDetails: String?
let feedURLString: String?
init(error: any Swift.Error,
retry: @escaping () -> Void,
dismiss: @escaping () -> Void,
technicalDetails: String? = nil,
feedURLString: String? = nil) {
self.error = error
self.retry = retry
self.dismiss = dismiss
self.technicalDetails = technicalDetails
self.feedURLString = feedURLString
}
}
struct Downloading {