feat(menubar): show CLI update banner when a newer version is available

This commit is contained in:
iamtoruk 2026-05-27 06:27:49 -07:00
parent a555c747d6
commit af99516633
2 changed files with 98 additions and 0 deletions

View file

@ -5,6 +5,7 @@ private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/re
private let checkIntervalSeconds: TimeInterval = 2 * 24 * 60 * 60
private let lastCheckKey = "UpdateChecker.lastCheckDate"
private let cachedVersionKey = "UpdateChecker.latestVersion"
private let cachedCliVersionKey = "UpdateChecker.latestCliVersion"
private let updateTimeoutSeconds: UInt64 = 120
private let maxUpdateStderrBytes = 64 * 1024
@ -28,6 +29,8 @@ private final class LockedDataBuffer: @unchecked Sendable {
@Observable
final class UpdateChecker {
var latestVersion: String?
var latestCliVersion: String?
var installedCliVersion: String?
var isUpdating = false
var updateError: String?
@ -40,15 +43,34 @@ final class UpdateChecker {
return normalizedLatest.compare(normalizedCurrent, options: .numeric) == .orderedDescending
}
var cliUpdateAvailable: Bool {
guard let latest = latestCliVersion, let installed = installedCliVersion else { return false }
let normalizedLatest = AppVersion.normalize(latest)
let normalizedInstalled = AppVersion.normalize(installed)
guard !normalizedInstalled.isEmpty else { return false }
return normalizedLatest.compare(normalizedInstalled, options: .numeric) == .orderedDescending
}
var cliUpdateCommand: String {
let argv = CodeburnCLI.baseArgv()
let path = argv.first ?? ""
if path.contains("/homebrew/") { return "brew upgrade codeburn" }
return "npm update -g codeburn"
}
var currentVersion: String {
AppVersion.normalizedBundleShortVersion
}
func checkIfNeeded() async {
if installedCliVersion == nil {
installedCliVersion = Self.queryInstalledCliVersion()
}
let lastCheck = UserDefaults.standard.double(forKey: lastCheckKey)
let now = Date().timeIntervalSince1970
if now - lastCheck < checkIntervalSeconds {
latestVersion = UserDefaults.standard.string(forKey: cachedVersionKey)
latestCliVersion = UserDefaults.standard.string(forKey: cachedCliVersionKey)
return
}
await check()
@ -56,6 +78,9 @@ final class UpdateChecker {
func check() async {
updateError = nil
if installedCliVersion == nil {
installedCliVersion = Self.queryInstalledCliVersion()
}
guard let url = URL(string: releasesAPI) else { return }
var request = URLRequest(url: url)
request.timeoutInterval = 30
@ -77,15 +102,43 @@ final class UpdateChecker {
.replacingOccurrences(of: "CodeBurnMenubar-", with: "")
.replacingOccurrences(of: ".zip", with: "")
let cliVersion = Self.resolveLatestCliVersion(in: releases)
latestVersion = version
latestCliVersion = cliVersion
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastCheckKey)
UserDefaults.standard.set(version, forKey: cachedVersionKey)
if let cliVersion { UserDefaults.standard.set(cliVersion, forKey: cachedCliVersionKey) }
} catch {
updateError = "Update check failed: \(error.localizedDescription)"
NSLog("CodeBurn: update check failed: \(error)")
}
}
nonisolated static func resolveLatestCliVersion(in releases: [GitHubRelease]) -> String? {
for release in releases where release.tag_name.hasPrefix("v") && !release.tag_name.hasPrefix("mac-v") {
return AppVersion.normalize(release.tag_name)
}
return nil
}
nonisolated static func queryInstalledCliVersion() -> String? {
let process = CodeburnCLI.makeProcess(subcommand: ["--version"])
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return nil }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return output.isEmpty ? nil : output
} catch {
return nil
}
}
nonisolated static func resolveLatestMenubarRelease(in releases: [GitHubRelease]) -> (release: GitHubRelease, asset: GitHubAsset)? {
for release in releases where release.tag_name.hasPrefix("mac-v") {
guard let asset = release.assets.first(where: {

View file

@ -82,6 +82,8 @@ struct MenuBarContent: View {
FooterBar()
CLIUpdateBanner()
StarBanner()
}
}
@ -459,6 +461,49 @@ struct FlameMark: View {
}
}
struct CLIUpdateBanner: View {
@Environment(UpdateChecker.self) private var updateChecker
var body: some View {
if updateChecker.cliUpdateAvailable {
HStack(spacing: 6) {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.blue)
Text("CLI \(updateChecker.latestCliVersion ?? "") available")
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(.primary)
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(updateChecker.cliUpdateCommand, forType: .string)
} label: {
HStack(spacing: 3) {
Text(updateChecker.cliUpdateCommand)
.font(.system(size: 10, weight: .medium, design: .monospaced))
Image(systemName: "doc.on.doc")
.font(.system(size: 8))
}
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
.help("Copy update command to clipboard")
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.blue.opacity(0.06))
.overlay(alignment: .top) {
Rectangle()
.fill(Color.secondary.opacity(0.18))
.frame(height: 0.5)
}
}
}
}
private let starBannerGitHubURL = URL(string: "https://github.com/getagentseal/codeburn")!
/// Shown at the very bottom on first launch. A small terracotta strip nudges users to star the