Improve update UI error details

This commit is contained in:
Lawrence Chen 2026-01-28 01:49:02 -08:00
parent 17a3e2033f
commit 0441efc675
18 changed files with 915 additions and 82 deletions

View file

@ -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

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

View file

@ -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(

View file

@ -139,6 +139,9 @@ struct ContentView: View {
.onPreferenceChange(SidebarFramePreferenceKey.self) { frame in
sidebarMinX = frame.minX
}
.background(WindowAccessor { window in
AppDelegate.shared?.attachUpdateAccessory(to: window)
})
}
}

View file

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

View file

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

View file

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

View file

@ -5,6 +5,11 @@ import Sparkle
class UpdateDriver: NSObject, SPUUserDriver {
let viewModel: UpdateViewModel
let standard: SPUStandardUserDriver
private let minimumCheckDuration: TimeInterval = UpdateTiming.minimumCheckDisplayDuration
private var lastCheckStart: Date?
private var pendingCheckTransition: DispatchWorkItem?
private var checkTimeoutWorkItem: DispatchWorkItem?
private var lastFeedURLString: String?
init(viewModel: UpdateViewModel, hostBundle: Bundle) {
self.viewModel = viewModel
@ -14,17 +19,19 @@ class UpdateDriver: NSObject, SPUUserDriver {
func show(_ request: SPUUpdatePermissionRequest,
reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
viewModel.state = .permissionRequest(.init(request: request, reply: { [weak viewModel] response in
UpdateLogStore.shared.append("show update permission request")
setState(.permissionRequest(.init(request: request, reply: { [weak viewModel] response in
viewModel?.state = .idle
reply(response)
}))
})))
if !hasUnobtrusiveTarget {
standard.show(request, reply: reply)
}
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
viewModel.state = .checking(.init(cancel: cancellation))
UpdateLogStore.shared.append("show user-initiated update check")
beginChecking(cancel: cancellation)
if !hasUnobtrusiveTarget {
standard.showUserInitiatedUpdateCheck(cancellation: cancellation)
}
@ -33,7 +40,8 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showUpdateFound(with appcastItem: SUAppcastItem,
state: SPUUserUpdateState,
reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply))
UpdateLogStore.shared.append("show update found: \(appcastItem.displayVersionString)")
setStateAfterMinimumCheckDelay(.updateAvailable(.init(appcastItem: appcastItem, reply: reply)))
if !hasUnobtrusiveTarget {
standard.showUpdateFound(with: appcastItem, state: state, reply: reply)
}
@ -49,7 +57,8 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showUpdateNotFoundWithError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
viewModel.state = .notFound(.init(acknowledgement: acknowledgement))
UpdateLogStore.shared.append("show update not found: \(formatErrorForLog(error))")
setStateAfterMinimumCheckDelay(.notFound(.init(acknowledgement: acknowledgement)))
if !hasUnobtrusiveTarget {
standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement)
@ -58,7 +67,9 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showUpdaterError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
viewModel.state = .error(.init(
let details = formatErrorForLog(error)
UpdateLogStore.shared.append("show updater error: \(details)")
setState(.error(.init(
error: error,
retry: { [weak viewModel] in
viewModel?.state = .idle
@ -69,7 +80,10 @@ class UpdateDriver: NSObject, SPUUserDriver {
},
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}))
},
technicalDetails: details,
feedURLString: lastFeedURLString
)))
if !hasUnobtrusiveTarget {
standard.showUpdaterError(error, acknowledgement: acknowledgement)
@ -79,10 +93,11 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {
viewModel.state = .downloading(.init(
UpdateLogStore.shared.append("show download initiated")
setState(.downloading(.init(
cancel: cancellation,
expectedLength: nil,
progress: 0))
progress: 0)))
if !hasUnobtrusiveTarget {
standard.showDownloadInitiated(cancellation: cancellation)
@ -90,14 +105,15 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
UpdateLogStore.shared.append("download expected length: \(expectedContentLength)")
guard case let .downloading(downloading) = viewModel.state else {
return
}
viewModel.state = .downloading(.init(
setState(.downloading(.init(
cancel: downloading.cancel,
expectedLength: expectedContentLength,
progress: 0))
progress: 0)))
if !hasUnobtrusiveTarget {
standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength)
@ -105,14 +121,15 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showDownloadDidReceiveData(ofLength length: UInt64) {
UpdateLogStore.shared.append("download received data: \(length)")
guard case let .downloading(downloading) = viewModel.state else {
return
}
viewModel.state = .downloading(.init(
setState(.downloading(.init(
cancel: downloading.cancel,
expectedLength: downloading.expectedLength,
progress: downloading.progress + length))
progress: downloading.progress + length)))
if !hasUnobtrusiveTarget {
standard.showDownloadDidReceiveData(ofLength: length)
@ -120,7 +137,8 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showDownloadDidStartExtractingUpdate() {
viewModel.state = .extracting(.init(progress: 0))
UpdateLogStore.shared.append("show extraction started")
setState(.extracting(.init(progress: 0)))
if !hasUnobtrusiveTarget {
standard.showDownloadDidStartExtractingUpdate()
@ -128,7 +146,8 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showExtractionReceivedProgress(_ progress: Double) {
viewModel.state = .extracting(.init(progress: progress))
UpdateLogStore.shared.append(String(format: "show extraction progress: %.2f", progress))
setState(.extracting(.init(progress: progress)))
if !hasUnobtrusiveTarget {
standard.showExtractionReceivedProgress(progress)
@ -136,6 +155,7 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
UpdateLogStore.shared.append("show ready to install")
if !hasUnobtrusiveTarget {
standard.showReady(toInstallAndRelaunch: reply)
} else {
@ -144,12 +164,13 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
viewModel.state = .installing(.init(
UpdateLogStore.shared.append("show installing update")
setState(.installing(.init(
retryTerminatingApplication: retryTerminatingApplication,
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}
))
)))
if !hasUnobtrusiveTarget {
standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication)
@ -157,8 +178,9 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
UpdateLogStore.shared.append("show update installed (relaunched=\(relaunched))")
standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement)
viewModel.state = .idle
setState(.idle)
}
func showUpdateInFocus() {
@ -168,14 +190,163 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
func dismissUpdateInstallation() {
viewModel.state = .idle
UpdateLogStore.shared.append("dismiss update installation")
if case .error = viewModel.state {
UpdateLogStore.shared.append("dismiss update installation ignored (error visible)")
standard.dismissUpdateInstallation()
return
}
setState(.idle)
standard.dismissUpdateInstallation()
}
private func beginChecking(cancel: @escaping () -> Void) {
runOnMain { [weak self] in
guard let self else { return }
viewModel.overrideState = nil
pendingCheckTransition?.cancel()
pendingCheckTransition = nil
checkTimeoutWorkItem?.cancel()
checkTimeoutWorkItem = nil
lastCheckStart = Date()
applyState(.checking(.init(cancel: cancel)))
scheduleCheckTimeout()
}
}
private func setStateAfterMinimumCheckDelay(_ newState: UpdateState) {
runOnMain { [weak self] in
guard let self else { return }
pendingCheckTransition?.cancel()
pendingCheckTransition = nil
checkTimeoutWorkItem?.cancel()
checkTimeoutWorkItem = nil
guard let start = lastCheckStart else {
lastCheckStart = nil
applyState(newState)
return
}
let elapsed = Date().timeIntervalSince(start)
if elapsed >= minimumCheckDuration {
lastCheckStart = nil
applyState(newState)
return
}
let delay = minimumCheckDuration - elapsed
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
guard case .checking = self.viewModel.state else { return }
self.lastCheckStart = nil
self.applyState(newState)
}
pendingCheckTransition = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
}
private func setState(_ newState: UpdateState) {
runOnMain { [weak self] in
guard let self else { return }
pendingCheckTransition?.cancel()
pendingCheckTransition = nil
checkTimeoutWorkItem?.cancel()
checkTimeoutWorkItem = nil
lastCheckStart = nil
applyState(newState)
}
}
private func scheduleCheckTimeout() {
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
guard case .checking = self.viewModel.state else { return }
self.setState(.notFound(.init(acknowledgement: {})))
}
checkTimeoutWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + UpdateTiming.checkTimeoutDuration, execute: workItem)
}
private func applyState(_ newState: UpdateState) {
viewModel.state = newState
UpdateLogStore.shared.append("state -> \(describe(newState))")
}
func resolvedFeedURLString() -> String? {
lastFeedURLString
}
func recordFeedURLString(_ feedURLString: String, usedFallback: Bool) {
if lastFeedURLString == feedURLString {
return
}
lastFeedURLString = feedURLString
let suffix = usedFallback ? " (fallback)" : ""
UpdateLogStore.shared.append("feed url resolved\(suffix): \(feedURLString)")
}
func formatErrorForLog(_ error: Error) -> String {
let nsError = error as NSError
var parts: [String] = ["\(nsError.domain)(\(nsError.code))"]
if !nsError.localizedDescription.isEmpty {
parts.append(nsError.localizedDescription)
}
if let url = nsError.userInfo[NSURLErrorFailingURLErrorKey] as? URL {
parts.append("url=\(url.absoluteString)")
} else if let urlString = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String {
parts.append("url=\(urlString)")
}
if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError {
let detail = "\(underlying.domain)(\(underlying.code)) \(underlying.localizedDescription)"
parts.append("underlying=\(detail)")
}
if let feed = lastFeedURLString {
parts.append("feed=\(feed)")
}
return parts.joined(separator: " | ")
}
private func describe(_ state: UpdateState) -> String {
switch state {
case .idle:
return "idle"
case .permissionRequest:
return "permissionRequest"
case .checking:
return "checking"
case .updateAvailable(let update):
return "updateAvailable(\(update.appcastItem.displayVersionString))"
case .notFound:
return "notFound"
case .error(let err):
return "error(\(err.error.localizedDescription))"
case .downloading(let download):
if let expected = download.expectedLength, expected > 0 {
let percent = Double(download.progress) / Double(expected) * 100
return String(format: "downloading(%.0f%%)", percent)
}
return "downloading"
case .extracting(let extracting):
return String(format: "extracting(%.0f%%)", extracting.progress * 100)
case .installing(let installing):
return "installing(auto=\(installing.isAutoUpdate))"
}
}
// MARK: No-Window Fallback
/// True if there is a target that can render our unobtrusive update checker.
var hasUnobtrusiveTarget: Bool {
NSApp.windows.contains { $0.isVisible }
}
private func runOnMain(_ action: @escaping () -> Void) {
if Thread.isMainThread {
action()
} else {
DispatchQueue.main.async(execute: action)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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?
}
}

View file

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

View file

@ -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") {

View 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())