diff --git a/CLAUDE.md b/CLAUDE.md index 730fc70e..14374cb6 100644 --- a/CLAUDE.md +++ b/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 diff --git a/GhosttyTabsUITests/UpdatePillUITests.swift b/GhosttyTabsUITests/UpdatePillUITests.swift new file mode 100644 index 00000000..d82b3a55 --- /dev/null +++ b/GhosttyTabsUITests/UpdatePillUITests.swift @@ -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 + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 1af2ddf6..a9faa94a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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( diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 54d0f9e2..9a8a218c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -139,6 +139,9 @@ struct ContentView: View { .onPreferenceChange(SidebarFramePreferenceKey.self) { frame in sidebarMinX = frame.minX } + .background(WindowAccessor { window in + AppDelegate.shared?.attachUpdateAccessory(to: window) + }) } } diff --git a/Sources/Update/UpdateBadge.swift b/Sources/Update/UpdateBadge.swift index f1837419..6baca5e9 100644 --- a/Sources/Update/UpdateBadge.swift +++ b/Sources/Update/UpdateBadge.swift @@ -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))) diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index e3e21b59..0eae08dd 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -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 diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index 35832a4b..a8869537 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -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" + } +} diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift index bf05c4ae..b4b19aa7 100644 --- a/Sources/Update/UpdateDriver.swift +++ b/Sources/Update/UpdateDriver.swift @@ -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) + } + } } diff --git a/Sources/Update/UpdateLogStore.swift b/Sources/Update/UpdateLogStore.swift new file mode 100644 index 00000000..8b21a1a8 --- /dev/null +++ b/Sources/Update/UpdateLogStore.swift @@ -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) + } + } +} diff --git a/Sources/Update/UpdatePill.swift b/Sources/Update/UpdatePill.swift index c3f4b72d..41c06855 100644 --- a/Sources/Update/UpdatePill.swift +++ b/Sources/Update/UpdatePill.swift @@ -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? 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 } } diff --git a/Sources/Update/UpdatePopoverView.swift b/Sources/Update/UpdatePopoverView.swift index 09831252..2b1fc3b1 100644 --- a/Sources/Update/UpdatePopoverView.swift +++ b/Sources/Update/UpdatePopoverView.swift @@ -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() diff --git a/Sources/Update/UpdateTestSupport.swift b/Sources/Update/UpdateTestSupport.swift new file mode 100644 index 00000000..daf9586c --- /dev/null +++ b/Sources/Update/UpdateTestSupport.swift @@ -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 diff --git a/Sources/Update/UpdateTiming.swift b/Sources/Update/UpdateTiming.swift new file mode 100644 index 00000000..4a2e7bee --- /dev/null +++ b/Sources/Update/UpdateTiming.swift @@ -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 +} diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift index 8d53caa0..bfc734b7 100644 --- a/Sources/Update/UpdateViewModel.swift +++ b/Sources/Update/UpdateViewModel.swift @@ -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 { diff --git a/Sources/WindowAccessor.swift b/Sources/WindowAccessor.swift new file mode 100644 index 00000000..884a154c --- /dev/null +++ b/Sources/WindowAccessor.swift @@ -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? + } +} diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 2817b082..fedadd35 100644 --- a/Sources/WindowToolbarController.swift +++ b/Sources/WindowToolbarController.swift @@ -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) + } + } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 9ec3645b..9331cd4b 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -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") { diff --git a/tests/test_update_timing.py b/tests/test_update_timing.py new file mode 100644 index 00000000..eea8b34f --- /dev/null +++ b/tests/test_update_timing.py @@ -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())