Improve update UI error details
This commit is contained in:
parent
17a3e2033f
commit
0441efc675
18 changed files with 915 additions and 82 deletions
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
Sources/Update/UpdateLogStore.swift
Normal file
63
Sources/Update/UpdateLogStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
48
Sources/Update/UpdateTestSupport.swift
Normal file
48
Sources/Update/UpdateTestSupport.swift
Normal 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
|
||||
7
Sources/Update/UpdateTiming.swift
Normal file
7
Sources/Update/UpdateTiming.swift
Normal 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
|
||||
}
|
||||
|
|
@ -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 can’t 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 can’t be found. Check your connection or try again later."
|
||||
case NSURLErrorCannotConnectToHost:
|
||||
return "cmux couldn’t 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 couldn’t be established. Try again later."
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
if nsError.domain == SUSparkleErrorDomain {
|
||||
switch nsError.code {
|
||||
case 2001:
|
||||
return "cmux couldn’t 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 update’s 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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue