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

@ -12,7 +12,7 @@ struct UpdateBadge: View {
@ViewBuilder
private var badgeContent: some View {
switch model.state {
switch model.effectiveState {
case .downloading(let download):
if let expectedLength = download.expectedLength, expectedLength > 0 {
let progress = min(1, max(0, Double(download.progress) / Double(expectedLength)))

View file

@ -67,6 +67,7 @@ class UpdateController {
/// Check for updates (used by the menu item).
@objc func checkForUpdates() {
UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))")
if viewModel.state == .idle {
updater.checkForUpdates()
return

View file

@ -3,7 +3,11 @@ import Cocoa
extension UpdateDriver: SPUUpdaterDelegate {
func feedURLString(for updater: SPUUpdater) -> String? {
Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String
let infoURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String
let fallback = "https://github.com/manaflow-ai/GhosttyTabs/releases/latest/download/appcast.xml"
let feedURLString = (infoURL?.isEmpty == false) ? infoURL! : fallback
recordFeedURLString(feedURLString, usedFallback: feedURLString == fallback)
return feedURLString
}
/// Called when an update is scheduled to install silently,
@ -19,6 +23,41 @@ extension UpdateDriver: SPUUpdaterDelegate {
return true
}
func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
let count = appcast.items.count
let firstVersion = appcast.items.first?.displayVersionString ?? ""
if firstVersion.isEmpty {
UpdateLogStore.shared.append("appcast loaded (items=\(count))")
} else {
UpdateLogStore.shared.append("appcast loaded (items=\(count), first=\(firstVersion))")
}
}
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
let version = item.displayVersionString
let fileURL = item.fileURL?.absoluteString ?? ""
if fileURL.isEmpty {
UpdateLogStore.shared.append("valid update found: \(version)")
} else {
UpdateLogStore.shared.append("valid update found: \(version) (\(fileURL))")
}
}
func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
let nsError = error as NSError
let reasonValue = (nsError.userInfo[SPUNoUpdateFoundReasonKey] as? NSNumber)?.intValue
let reason = reasonValue.map { SPUNoUpdateFoundReason(rawValue: OSStatus($0)) } ?? nil
let reasonText = reason.map(describeNoUpdateFoundReason) ?? "unknown"
let userInitiated = (nsError.userInfo[SPUNoUpdateFoundUserInitiatedKey] as? NSNumber)?.boolValue ?? false
let latestItem = nsError.userInfo[SPULatestAppcastItemFoundKey] as? SUAppcastItem
let latestVersion = latestItem?.displayVersionString ?? ""
if latestVersion.isEmpty {
UpdateLogStore.shared.append("no update found (reason=\(reasonText), userInitiated=\(userInitiated))")
} else {
UpdateLogStore.shared.append("no update found (reason=\(reasonText), userInitiated=\(userInitiated), latest=\(latestVersion))")
}
}
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
NSApp.invalidateRestorableState()
for window in NSApp.windows {
@ -26,3 +65,20 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
}
}
private func describeNoUpdateFoundReason(_ reason: SPUNoUpdateFoundReason) -> String {
switch reason {
case .unknown:
return "unknown"
case .onLatestVersion:
return "onLatestVersion"
case .onNewerThanLatestVersion:
return "onNewerThanLatestVersion"
case .systemIsTooOld:
return "systemIsTooOld"
case .systemIsTooNew:
return "systemIsTooNew"
@unknown default:
return "unknown"
}
}

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

View file

@ -0,0 +1,63 @@
import Foundation
import AppKit
final class UpdateLogStore {
static let shared = UpdateLogStore()
private let queue = DispatchQueue(label: "cmux.update.log")
private var entries: [String] = []
private let maxEntries = 200
private let logURL: URL
private let formatter: ISO8601DateFormatter
private init() {
formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let logsDir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
logURL = logsDir.appendingPathComponent("Logs/cmux-update.log")
ensureLogFile()
}
func append(_ message: String) {
let timestamp = formatter.string(from: Date())
let line = "[\(timestamp)] \(message)"
queue.async { [weak self] in
guard let self else { return }
entries.append(line)
if entries.count > maxEntries {
entries.removeFirst(entries.count - maxEntries)
}
appendToFile(line: line)
}
}
func snapshot() -> String {
queue.sync {
entries.joined(separator: "\n")
}
}
func logPath() -> String {
logURL.path
}
private func ensureLogFile() {
let directory = logURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
if !FileManager.default.fileExists(atPath: logURL.path) {
try? Data().write(to: logURL)
}
}
private func appendToFile(line: String) {
let data = Data((line + "\n").utf8)
if let handle = try? FileHandle(forWritingTo: logURL) {
try? handle.seekToEnd()
try? handle.write(contentsOf: data)
try? handle.close()
} else {
try? data.write(to: logURL, options: .atomic)
}
}
}

View file

@ -1,31 +1,41 @@
import AppKit
import Foundation
import SwiftUI
/// A pill-shaped button that displays update status and provides access to update actions.
struct UpdatePill: View {
@ObservedObject var model: UpdateViewModel
var showWhenIdle: Bool = false
var idleText: String = "Check for Updates"
var onIdleTap: (() -> Void)?
@State private var showPopover = false
@State private var resetTask: Task<Void, Never>?
private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium)
var body: some View {
if !model.state.isIdle || showWhenIdle {
let state = model.effectiveState
if !state.isIdle {
pillButton
.popover(isPresented: $showPopover, arrowEdge: .bottom) {
.popover(
isPresented: $showPopover,
attachmentAnchor: .rect(.bounds),
arrowEdge: .top
) {
UpdatePopoverView(model: model)
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
.onChange(of: model.state) { newState in
.onChange(of: model.effectiveState) { newState in
resetTask?.cancel()
if case .notFound(let notFound) = newState {
if case .notFound(let notFound) = newState, model.overrideState == nil {
recordUITestTimestamp(key: "noUpdateShownAt")
resetTask = Task { [weak model] in
try? await Task.sleep(for: .seconds(5))
let delay = UInt64(UpdateTiming.noUpdateDisplayDuration * 1_000_000_000)
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled, case .notFound? = model?.state else { return }
model?.state = .idle
await MainActor.run {
withAnimation(.easeInOut(duration: 0.25)) {
recordUITestTimestamp(key: "noUpdateHiddenAt")
model?.state = .idle
}
}
notFound.acknowledgement()
}
} else {
@ -38,14 +48,6 @@ struct UpdatePill: View {
@ViewBuilder
private var pillButton: some View {
Button(action: {
if model.state.isIdle && showWhenIdle {
if let onIdleTap {
onIdleTap()
} else {
showPopover.toggle()
}
return
}
if case .notFound(let notFound) = model.state {
model.state = .idle
notFound.acknowledgement()
@ -54,16 +56,10 @@ struct UpdatePill: View {
}
}) {
HStack(spacing: 6) {
if model.state.isIdle && showWhenIdle {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundColor(.secondary)
.frame(width: 14, height: 14)
} else {
UpdateBadge(model: model)
.frame(width: 14, height: 14)
}
UpdateBadge(model: model)
.frame(width: 14, height: 14)
Text(displayText)
Text(model.text)
.font(Font(textFont))
.lineLimit(1)
.truncationMode(.tail)
@ -79,18 +75,33 @@ struct UpdatePill: View {
.contentShape(Capsule())
}
.buttonStyle(.plain)
.help(displayText)
.accessibilityLabel(displayText)
.help(model.text)
.accessibilityLabel(model.text)
.accessibilityIdentifier("UpdatePill")
}
private var textWidth: CGFloat? {
let attributes: [NSAttributedString.Key: Any] = [.font: textFont]
let text = model.state.isIdle && showWhenIdle ? idleText : model.maxWidthText
let size = (text as NSString).size(withAttributes: attributes)
let size = (model.maxWidthText as NSString).size(withAttributes: attributes)
return size.width
}
private var displayText: String {
model.state.isIdle && showWhenIdle ? idleText : model.text
private func recordUITestTimestamp(key: String) {
#if DEBUG
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_MODE"] == "1" else { return }
guard let path = env["CMUX_UI_TEST_TIMING_PATH"] else { return }
let url = URL(fileURLWithPath: path)
var payload: [String: Double] = [:]
if let data = try? Data(contentsOf: url),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Double] {
payload = object
}
payload[key] = Date().timeIntervalSince1970
if let data = try? JSONSerialization.data(withJSONObject: payload) {
try? data.write(to: url)
}
#endif
}
}

View file

@ -9,7 +9,7 @@ struct UpdatePopoverView: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
switch model.state {
switch model.effectiveState {
case .idle:
EmptyView()
@ -338,23 +338,48 @@ fileprivate struct UpdateErrorView: View {
let dismiss: DismissAction
var body: some View {
let title = UpdateViewModel.userFacingErrorTitle(for: error.error)
let message = UpdateViewModel.userFacingErrorMessage(for: error.error)
let details = UpdateViewModel.errorDetails(
for: error.error,
technicalDetails: error.technicalDetails,
feedURLString: error.feedURLString
)
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.system(size: 13))
Text("Update Failed")
Text(title)
.font(.system(size: 13, weight: .semibold))
}
Text(error.error.localizedDescription)
Text(message)
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
VStack(alignment: .leading, spacing: 6) {
Text("Details")
.font(.system(size: 11, weight: .semibold))
Text(details)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
HStack(spacing: 8) {
Button("Copy Details") {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(details, forType: .string)
}
.controlSize(.small)
Button("OK") {
error.dismiss()
dismiss()

View file

@ -0,0 +1,48 @@
#if DEBUG
import Foundation
import Sparkle
enum UpdateTestSupport {
static func applyIfNeeded(to viewModel: UpdateViewModel) {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_MODE"] == "1" else { return }
guard let state = env["CMUX_UI_TEST_UPDATE_STATE"] else { return }
DispatchQueue.main.async {
switch state {
case "available":
let version = env["CMUX_UI_TEST_UPDATE_VERSION"] ?? "9.9.9"
transition(to: .updateAvailable(.init(
appcastItem: makeAppcastItem(displayVersion: version) ?? SUAppcastItem.empty(),
reply: { _ in }
)), on: viewModel)
case "notFound":
transition(to: .notFound(.init(acknowledgement: {})), on: viewModel)
default:
break
}
}
}
private static func transition(to state: UpdateState, on viewModel: UpdateViewModel) {
viewModel.state = .checking(.init(cancel: {}))
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
viewModel.state = state
}
}
private static func makeAppcastItem(displayVersion: String) -> SUAppcastItem? {
let enclosure: [String: Any] = [
"url": "https://example.com/cmux.zip",
"length": "1024",
"sparkle:version": displayVersion,
"sparkle:shortVersionString": displayVersion,
]
let dict: [String: Any] = [
"title": "cmux \(displayVersion)",
"enclosure": enclosure,
]
return SUAppcastItem(dictionary: dict)
}
}
#endif

View file

@ -0,0 +1,7 @@
import Foundation
enum UpdateTiming {
static let minimumCheckDisplayDuration: TimeInterval = 2.0
static let noUpdateDisplayDuration: TimeInterval = 5.0
static let checkTimeoutDuration: TimeInterval = 10.0
}

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 {