Improve update UI error details
This commit is contained in:
parent
17a3e2033f
commit
0441efc675
18 changed files with 915 additions and 82 deletions
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -2,12 +2,26 @@
|
|||
|
||||
## Local dev
|
||||
|
||||
After making code changes, always run this flow:
|
||||
After making code changes, always run the build:
|
||||
|
||||
```bash
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build
|
||||
```
|
||||
|
||||
`reload` = kill and launch the Debug app only:
|
||||
|
||||
```bash
|
||||
pkill -x "cmux DEV" || true
|
||||
sleep 0.2
|
||||
open /Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Debug/cmux\ DEV.app
|
||||
```
|
||||
|
||||
`reload-prod` = kill and launch the Release app:
|
||||
|
||||
```bash
|
||||
pkill -x cmux || true
|
||||
open /Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Debug/cmux.app
|
||||
sleep 0.2
|
||||
open /Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Release/cmux.app
|
||||
```
|
||||
|
||||
## Release
|
||||
|
|
|
|||
87
GhosttyTabsUITests/UpdatePillUITests.swift
Normal file
87
GhosttyTabsUITests/UpdatePillUITests.swift
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import XCTest
|
||||
import Foundation
|
||||
|
||||
final class UpdatePillUITests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testUpdatePillShowsForAvailableUpdate() {
|
||||
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
|
||||
systemSettings.terminate()
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_UPDATE_STATE"] = "available"
|
||||
app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9"
|
||||
app.launch()
|
||||
app.activate()
|
||||
|
||||
let pill = app.descendants(matching: .any)["UpdatePill"]
|
||||
XCTAssertTrue(pill.waitForExistence(timeout: 5.0))
|
||||
XCTAssertTrue(waitForLabel(pill, label: "Update Available: 9.9.9", timeout: 5.0))
|
||||
assertVisibleSize(pill)
|
||||
attachScreenshot(name: "update-available")
|
||||
attachScreenshot(name: "update-available-pill", screenshot: pill.screenshot())
|
||||
}
|
||||
|
||||
func testUpdatePillShowsForNoUpdateThenDismisses() {
|
||||
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
|
||||
systemSettings.terminate()
|
||||
let timingPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-ui-test-timing-\(UUID().uuidString).json")
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_UPDATE_STATE"] = "notFound"
|
||||
app.launchEnvironment["CMUX_UI_TEST_TIMING_PATH"] = timingPath.path
|
||||
app.launch()
|
||||
app.activate()
|
||||
|
||||
let pill = app.descendants(matching: .any)["UpdatePill"]
|
||||
XCTAssertTrue(pill.waitForExistence(timeout: 5.0))
|
||||
XCTAssertTrue(waitForLabel(pill, label: "No Updates Available", timeout: 5.0))
|
||||
assertVisibleSize(pill)
|
||||
attachScreenshot(name: "no-updates")
|
||||
attachScreenshot(name: "no-updates-pill", screenshot: pill.screenshot())
|
||||
|
||||
let gone = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate(format: "exists == false"),
|
||||
object: pill
|
||||
)
|
||||
XCTAssertEqual(XCTWaiter().wait(for: [gone], timeout: 7.0), .completed)
|
||||
|
||||
let payload = loadTimingPayload(from: timingPath)
|
||||
let shownAt = payload["noUpdateShownAt"] ?? 0
|
||||
let hiddenAt = payload["noUpdateHiddenAt"] ?? 0
|
||||
XCTAssertGreaterThan(shownAt, 0)
|
||||
XCTAssertGreaterThan(hiddenAt, shownAt)
|
||||
XCTAssertGreaterThanOrEqual(hiddenAt - shownAt, 4.8)
|
||||
}
|
||||
|
||||
private func waitForLabel(_ element: XCUIElement, label: String, timeout: TimeInterval) -> Bool {
|
||||
let predicate = NSPredicate(format: "label == %@", label)
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func assertVisibleSize(_ element: XCUIElement) {
|
||||
let size = element.frame.size
|
||||
XCTAssertGreaterThan(size.width, 20)
|
||||
XCTAssertGreaterThan(size.height, 10)
|
||||
}
|
||||
|
||||
private func attachScreenshot(name: String, screenshot: XCUIScreenshot = XCUIScreen.main.screenshot()) {
|
||||
let attachment = XCTAttachment(screenshot: screenshot)
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
private func loadTimingPayload(from url: URL) -> [String: Double] {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
|
||||
return [:]
|
||||
}
|
||||
return object
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
configureUserNotifications()
|
||||
updateController.startUpdater()
|
||||
titlebarAccessoryController.start()
|
||||
#if DEBUG
|
||||
UpdateTestSupport.applyIfNeeded(to: updateController.viewModel)
|
||||
#endif
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
|
|
@ -42,13 +45,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
|
||||
@objc func checkForUpdates(_ sender: Any?) {
|
||||
updateViewModel.overrideState = nil
|
||||
updateController.checkForUpdates()
|
||||
}
|
||||
|
||||
@objc func showUpdatePill(_ sender: Any?) {
|
||||
updateViewModel.overrideState = .notFound(.init(acknowledgement: {}))
|
||||
}
|
||||
|
||||
@objc func showUpdatePillLoading(_ sender: Any?) {
|
||||
updateViewModel.overrideState = .checking(.init(cancel: {}))
|
||||
}
|
||||
|
||||
@objc func hideUpdatePill(_ sender: Any?) {
|
||||
updateViewModel.overrideState = .idle
|
||||
}
|
||||
|
||||
@objc func clearUpdatePillOverride(_ sender: Any?) {
|
||||
updateViewModel.overrideState = nil
|
||||
}
|
||||
|
||||
@objc func copyUpdateLogs(_ sender: Any?) {
|
||||
let logText = UpdateLogStore.shared.snapshot()
|
||||
let payload: String
|
||||
if logText.isEmpty {
|
||||
payload = "No update logs captured.\nLog file: \(UpdateLogStore.shared.logPath())"
|
||||
} else {
|
||||
payload = logText + "\nLog file: \(UpdateLogStore.shared.logPath())"
|
||||
}
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(payload, forType: .string)
|
||||
}
|
||||
|
||||
func attachUpdateAccessory(to window: NSWindow) {
|
||||
titlebarAccessoryController.start()
|
||||
titlebarAccessoryController.attach(to: window)
|
||||
}
|
||||
|
||||
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
||||
updateController.validateMenuItem(item)
|
||||
}
|
||||
|
||||
|
||||
private func configureUserNotifications() {
|
||||
let actions = [
|
||||
UNNotificationAction(
|
||||
|
|
|
|||
|
|
@ -139,6 +139,9 @@ struct ContentView: View {
|
|||
.onPreferenceChange(SidebarFramePreferenceKey.self) { frame in
|
||||
sidebarMinX = frame.minX
|
||||
}
|
||||
.background(WindowAccessor { window in
|
||||
AppDelegate.shared?.attachUpdateAccessory(to: window)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
29
Sources/WindowAccessor.swift
Normal file
29
Sources/WindowAccessor.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct WindowAccessor: NSViewRepresentable {
|
||||
let onWindow: (NSWindow) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
NSView()
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
DispatchQueue.main.async { [weak nsView] in
|
||||
guard let window = nsView?.window else { return }
|
||||
guard context.coordinator.lastWindow !== window else { return }
|
||||
context.coordinator.lastWindow = window
|
||||
onWindow(window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WindowAccessor {
|
||||
final class Coordinator {
|
||||
weak var lastWindow: NSWindow?
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
||||
|
|
@ -10,6 +11,8 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
|||
|
||||
private var commandLabels: [ObjectIdentifier: NSTextField] = [:]
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private var updateSizeCancellables: [ObjectIdentifier: AnyCancellable] = [:]
|
||||
private var updateViewConstraints: [ObjectIdentifier: (width: NSLayoutConstraint, height: NSLayoutConstraint)] = [:]
|
||||
|
||||
init(updateViewModel: UpdateViewModel) {
|
||||
self.updateViewModel = updateViewModel
|
||||
|
|
@ -20,6 +23,9 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
|||
for observer in observers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
for cancellable in updateSizeCancellables.values {
|
||||
cancellable.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func start(tabManager: TabManager) {
|
||||
|
|
@ -68,12 +74,13 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
|||
let toolbar = NSToolbar(identifier: NSToolbar.Identifier("cmux.toolbar"))
|
||||
toolbar.delegate = self
|
||||
toolbar.displayMode = .iconOnly
|
||||
toolbar.sizeMode = .small
|
||||
toolbar.allowsUserCustomization = false
|
||||
toolbar.autosavesConfiguration = false
|
||||
toolbar.showsBaselineSeparator = false
|
||||
window.toolbar = toolbar
|
||||
window.toolbarStyle = .unified
|
||||
window.titleVisibility = .visible
|
||||
window.toolbarStyle = .unifiedCompact
|
||||
window.titleVisibility = .hidden
|
||||
}
|
||||
|
||||
private func updateFocusedCommandText() {
|
||||
|
|
@ -118,18 +125,39 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
|||
|
||||
if itemIdentifier == updateItemIdentifier, let updateViewModel {
|
||||
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
|
||||
let view = NonDraggableHostingView(rootView: UpdatePill(
|
||||
model: updateViewModel,
|
||||
showWhenIdle: true,
|
||||
onIdleTap: {
|
||||
guard let delegate = NSApp.delegate as? AppDelegate else { return }
|
||||
delegate.checkForUpdates(nil)
|
||||
}
|
||||
))
|
||||
let view = NonDraggableHostingView(rootView: UpdatePill(model: updateViewModel))
|
||||
let key = ObjectIdentifier(toolbar)
|
||||
item.view = view
|
||||
sizeToolbarItem(for: key, hostingView: view)
|
||||
updateSizeCancellables[key]?.cancel()
|
||||
updateSizeCancellables[key] = updateViewModel.$state
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self, weak view] _ in
|
||||
guard let self, let view else { return }
|
||||
self.sizeToolbarItem(for: key, hostingView: view)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func sizeToolbarItem(for key: ObjectIdentifier, hostingView: NSView) {
|
||||
hostingView.invalidateIntrinsicContentSize()
|
||||
hostingView.layoutSubtreeIfNeeded()
|
||||
let size = hostingView.fittingSize
|
||||
hostingView.setFrameSize(size)
|
||||
hostingView.setContentHuggingPriority(.required, for: .horizontal)
|
||||
hostingView.setContentHuggingPriority(.required, for: .vertical)
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
if let constraints = updateViewConstraints[key] {
|
||||
constraints.width.constant = size.width
|
||||
constraints.height.constant = size.height
|
||||
} else {
|
||||
let width = hostingView.widthAnchor.constraint(equalToConstant: size.width)
|
||||
let height = hostingView.heightAnchor.constraint(equalToConstant: size.height)
|
||||
NSLayoutConstraint.activate([width, height])
|
||||
updateViewConstraints[key] = (width: width, height: height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,14 +29,33 @@ struct cmuxApp: App {
|
|||
Button("About cmux") {
|
||||
showAboutPanel()
|
||||
}
|
||||
}
|
||||
|
||||
CommandGroup(after: .appInfo) {
|
||||
Divider()
|
||||
Button("Check for Updates…") {
|
||||
appDelegate.checkForUpdates(nil)
|
||||
}
|
||||
}
|
||||
|
||||
CommandMenu("Update Pill") {
|
||||
Button("Show Update Pill") {
|
||||
appDelegate.showUpdatePill(nil)
|
||||
}
|
||||
Button("Show Loading State") {
|
||||
appDelegate.showUpdatePillLoading(nil)
|
||||
}
|
||||
Button("Hide Update Pill") {
|
||||
appDelegate.hideUpdatePill(nil)
|
||||
}
|
||||
Button("Automatic Update Pill") {
|
||||
appDelegate.clearUpdatePillOverride(nil)
|
||||
}
|
||||
}
|
||||
|
||||
CommandMenu("Update Logs") {
|
||||
Button("Copy Update Logs") {
|
||||
appDelegate.copyUpdateLogs(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// New tab commands
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Tab") {
|
||||
|
|
|
|||
54
tests/test_update_timing.py
Normal file
54
tests/test_update_timing.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Verify update UI timing constants so update indicators are visible long enough.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TIMING_FILE = ROOT / "Sources" / "Update" / "UpdateTiming.swift"
|
||||
|
||||
|
||||
def read_constants(text: str) -> dict[str, float]:
|
||||
constants = {}
|
||||
pattern = re.compile(r"static let (\w+): TimeInterval = ([0-9.]+)")
|
||||
for match in pattern.finditer(text):
|
||||
constants[match.group(1)] = float(match.group(2))
|
||||
return constants
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not TIMING_FILE.exists():
|
||||
print(f"Missing {TIMING_FILE}")
|
||||
return 1
|
||||
|
||||
constants = read_constants(TIMING_FILE.read_text())
|
||||
required = {
|
||||
"minimumCheckDisplayDuration": 2.0,
|
||||
"noUpdateDisplayDuration": 5.0,
|
||||
}
|
||||
|
||||
failures = []
|
||||
for name, expected in required.items():
|
||||
actual = constants.get(name)
|
||||
if actual is None:
|
||||
failures.append(f"{name} missing")
|
||||
continue
|
||||
if actual != expected:
|
||||
failures.append(f"{name} = {actual} (expected {expected})")
|
||||
|
||||
if failures:
|
||||
print("Update timing test failed:")
|
||||
for failure in failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
|
||||
print("Update timing test passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue