fix(menubar): clarify update failure status

This commit is contained in:
hao 2026-07-26 15:20:13 +08:00
parent 2f8e5bddcd
commit 4e9c3a1dfe
3 changed files with 97 additions and 4 deletions

View file

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

View file

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

View file

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