From af99516633bb4993abf34e55c25d6bb2aeebac01 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 27 May 2026 06:27:49 -0700 Subject: [PATCH] feat(menubar): show CLI update banner when a newer version is available --- .../CodeBurnMenubar/Data/UpdateChecker.swift | 53 +++++++++++++++++++ .../Views/MenuBarContent.swift | 45 ++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift index 3fcd0e2..79a7625 100644 --- a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift +++ b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift @@ -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: { diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 12dec27..389f909 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -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