From 4e9c3a1dfe41b232a4eee7bf8bef632e83bc4b11 Mon Sep 17 00:00:00 2001 From: hao Date: Sun, 26 Jul 2026 15:20:13 +0800 Subject: [PATCH] fix(menubar): clarify update failure status --- .../CodeBurnMenubar/Data/UpdateChecker.swift | 53 ++++++++++++++++++- .../Views/MenuBarContent.swift | 10 ++-- .../UpdateCheckerTests.swift | 38 +++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift index 8028bde..c7d2459 100644 --- a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift +++ b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift @@ -14,6 +14,35 @@ private let maxUpdateStderrBytes = 64 * 1024 // `menubar --force`, so we refuse to run them and ask the user to upgrade the CLI first. private let minCliVersionForUpdate = "0.9.9" +enum UpdateFailureStage: Equatable { + case check + case cliUpdate + case menubarUpdate + + var badgeLabel: String { + switch self { + case .check: "Update Check Failed" + case .cliUpdate: "CLI Update Failed" + case .menubarUpdate: "Menubar Update Failed" + } + } + + var summary: String { + switch self { + case .check: "CodeBurn could not check GitHub for updates." + case .cliUpdate: "CodeBurn could not update the CLI." + case .menubarUpdate: "CodeBurn could not update the menubar app." + } + } + + var retryHelp: String { + switch self { + case .check: "Click to retry the update check." + case .cliUpdate, .menubarUpdate: "Click to retry the update." + } + } +} + private final class LockedDataBuffer: @unchecked Sendable { private let lock = NSLock() private var data = Data() @@ -38,6 +67,19 @@ final class UpdateChecker { var installedCliVersion: String? var isUpdating = false var updateError: String? + var updateFailureStage: UpdateFailureStage? + + var updateBadgeLabel: String { + if isUpdating { return "Updating..." } + return updateFailureStage?.badgeLabel ?? "Update" + } + + var updateHelpText: String { + guard let error = updateError, let stage = updateFailureStage else { + return "Update the CLI and menubar to the latest release" + } + return "\(stage.summary)\n\n\(error)\n\n\(stage.retryHelp)" + } var updateAvailable: Bool { guard let latest = latestVersion else { return false } @@ -88,6 +130,7 @@ final class UpdateChecker { func check() async { updateError = nil + updateFailureStage = nil installedCliVersion = Self.queryInstalledCliVersion() guard let url = URL(string: releasesAPI) else { return } var request = URLRequest(url: url) @@ -118,7 +161,8 @@ final class UpdateChecker { UserDefaults.standard.set(version, forKey: cachedVersionKey) if let cliVersion { UserDefaults.standard.set(cliVersion, forKey: cachedCliVersionKey) } } catch { - updateError = "Update check failed: \(error.localizedDescription)" + updateFailureStage = .check + updateError = error.localizedDescription NSLog("CodeBurn: update check failed: \(error)") } } @@ -195,9 +239,11 @@ final class UpdateChecker { if cliUpdateAvailable || cliTooOldForUpdate { isUpdating = true updateError = nil + updateFailureStage = nil let cliPath = CodeburnCLI.baseArgv().first ?? "" guard let argv = Self.cliUpdateInvocation(cliPath: cliPath), let bin = argv.first else { isUpdating = false + updateFailureStage = .cliUpdate updateError = "Could not find the package manager for \(cliPath.isEmpty ? "the CLI" : cliPath). Run \u{201C}\(cliUpdateCommand)\u{201D} manually, then try again." return } @@ -209,6 +255,7 @@ final class UpdateChecker { guard let self else { return } if status != 0 { self.isUpdating = false + self.updateFailureStage = .cliUpdate self.updateError = stderr.isEmpty ? "CLI update failed (exit \(status))" : stderr NSLog("CodeBurn: CLI update failed (exit \(status)): \(stderr)") return @@ -263,11 +310,13 @@ final class UpdateChecker { func performUpdate() { installedCliVersion = Self.queryInstalledCliVersion() if cliTooOldForUpdate { + updateFailureStage = .menubarUpdate updateError = "Your codeburn CLI (\(AppVersion.display(installedCliVersion ?? ""))) is too old to update the menubar. Run “\(cliUpdateCommand)” first, then try again." return } isUpdating = true updateError = nil + updateFailureStage = nil let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"]) let errPipe = Pipe() @@ -297,6 +346,7 @@ final class UpdateChecker { guard let self else { return } self.isUpdating = false if proc.terminationStatus != 0 { + self.updateFailureStage = .menubarUpdate self.updateError = stderr.isEmpty ? "Update failed (exit \(proc.terminationStatus))" : stderr NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)") } else { @@ -309,6 +359,7 @@ final class UpdateChecker { try process.run() } catch { isUpdating = false + updateFailureStage = .menubarUpdate updateError = error.localizedDescription NSLog("CodeBurn: update spawn failed: \(error)") } diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index d92bd74..9257b2f 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -520,7 +520,9 @@ private struct UpdateBadge: View { var body: some View { Button { - if updateChecker.updateAvailable || updateChecker.cliUpdateAvailable { + if updateChecker.updateFailureStage == .check { + Task { await updateChecker.check() } + } else if updateChecker.updateAvailable || updateChecker.cliUpdateAvailable { updateChecker.performFullUpdate() } else { Task { await updateChecker.check() } @@ -538,7 +540,7 @@ private struct UpdateBadge: View { Image(systemName: "arrow.down.circle.fill") .font(.system(size: 10)) } - Text(updateChecker.isUpdating ? "Updating..." : (updateChecker.updateError == nil ? "Update" : "Failed")) + Text(updateChecker.updateBadgeLabel) .font(.system(size: 10, weight: .medium)) } .padding(.horizontal, 8) @@ -548,7 +550,9 @@ private struct UpdateBadge: View { .tint(Theme.brandAccent) .controlSize(.mini) .disabled(updateChecker.isUpdating) - .help(updateChecker.updateError ?? "Update the CLI and menubar to the latest release") + .help(updateChecker.updateHelpText) + .accessibilityLabel(updateChecker.updateBadgeLabel) + .accessibilityHint(updateChecker.updateHelpText) } } diff --git a/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift index 6c4a99f..1abf5f0 100644 --- a/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift @@ -59,6 +59,44 @@ struct UpdateCheckerTests { } } +@Suite("Update failure presentation") +@MainActor +struct UpdateFailurePresentationTests { + @Test("identifies an automatic update check failure") + func updateCheckFailure() { + let checker = UpdateChecker() + checker.updateFailureStage = .check + checker.updateError = "GitHub returned HTTP 403." + + #expect(checker.updateBadgeLabel == "Update Check Failed") + #expect(checker.updateHelpText.contains("could not check GitHub for updates")) + #expect(checker.updateHelpText.contains("GitHub returned HTTP 403.")) + #expect(checker.updateHelpText.contains("retry the update check")) + } + + @Test("distinguishes CLI update failures") + func cliUpdateFailure() { + let checker = UpdateChecker() + checker.updateFailureStage = .cliUpdate + checker.updateError = "npm exited with status 1." + + #expect(checker.updateBadgeLabel == "CLI Update Failed") + #expect(checker.updateHelpText.contains("could not update the CLI")) + #expect(checker.updateHelpText.contains("npm exited with status 1.")) + } + + @Test("distinguishes menubar update failures") + func menubarUpdateFailure() { + let checker = UpdateChecker() + checker.updateFailureStage = .menubarUpdate + checker.updateError = "Checksum mismatch." + + #expect(checker.updateBadgeLabel == "Menubar Update Failed") + #expect(checker.updateHelpText.contains("could not update the menubar app")) + #expect(checker.updateHelpText.contains("Checksum mismatch.")) + } +} + // MARK: - one-click full update: package-manager resolution