diff --git a/.github/workflows/release-menubar.yml b/.github/workflows/release-menubar.yml index b2cf9497..db413344 100644 --- a/.github/workflows/release-menubar.yml +++ b/.github/workflows/release-menubar.yml @@ -2,8 +2,8 @@ name: Release macOS Menubar # Triggers on a `mac-v*` tag push (e.g. `git tag mac-v0.8.0 && git push origin mac-v0.8.0`), # or manually via the Actions tab. Builds a universal arm64+x86_64 bundle, ad-hoc signs it, -# zips via `ditto`, and uploads the zip to the GitHub Release. `npx codeburn menubar` clears -# the download quarantine flag on install so Gatekeeper stays quiet. +# zips via `ditto`, and uploads the zip to the GitHub Release. The installer verifies +# the checksum and bundle identity before replacing the local app. on: push: tags: @@ -60,13 +60,15 @@ jobs: Install with: ``` - npx codeburn menubar + npm install -g codeburn + codeburn menubar ``` - That command drops the app into `~/Applications`, clears the download - quarantine, and launches it. If you download the zip from this page directly - and macOS shows "cannot verify developer", right-click the app in Finder and - pick Open to whitelist it once. + That command drops the app into `~/Applications`, records the persistent + `codeburn` CLI path used by the menubar, verifies the downloaded checksum, + clears quarantine after bundle verification, and launches it. If you download + the zip from this page directly and macOS shows "cannot verify developer", + right-click the app in Finder and pick Open to whitelist it once. files: | mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip.sha256 diff --git a/RELEASING.md b/RELEASING.md index 56e41242..b42d7d82 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -120,25 +120,25 @@ git push origin mac-v0.9.8 The `.github/workflows/release-menubar.yml` workflow automatically detects the `mac-v*` tag and: 1. Checks out the repo -2. Runs `mac/Scripts/package-app.sh 0.9.8` +2. Runs `mac/Scripts/package-app.sh v0.9.8` 3. Signs the app bundle (ad-hoc signing) -4. Creates a zip file: `CodeBurnMenubar-0.9.8.zip` -5. Computes a SHA-256 checksum: `CodeBurnMenubar-0.9.8.zip.sha256` +4. Creates a zip file: `CodeBurnMenubar-v0.9.8.zip` +5. Computes a SHA-256 checksum: `CodeBurnMenubar-v0.9.8.zip.sha256` 6. Uploads both to a GitHub Release named "Menubar v0.9.8" The script output on the build machine shows: ``` -✓ Built /path/mac/.build/dist/CodeBurnMenubar-0.9.8.zip -✓ Checksum /path/mac/.build/dist/CodeBurnMenubar-0.9.8.zip.sha256 - CodeBurnMenubar-0.9.8.zip +✓ Built /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip +✓ Checksum /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256 + CodeBurnMenubar-v0.9.8.zip ``` No manual action is needed; the workflow handles everything. ### 4. Verify the Release -After the workflow completes, the GitHub Release page shows the zip and sha256 files. The menubar installer command in the CLI calls `npx codeburn menubar`, which fetches the latest release from GitHub and installs it into `~/Applications`. +After the workflow completes, the GitHub Release page shows the zip and sha256 files. The installed CLI command `codeburn menubar --force` fetches the newest `mac-v*` menubar release that includes both assets, verifies the checksum and bundle identity, and installs it into `~/Applications`. ## Homebrew Tap Update @@ -227,12 +227,12 @@ If a release is published with broken assets (e.g., a menubar zip with a build e Use `gh release upload` with the `--clobber` flag to overwrite existing files: ```bash -# After re-running mac/Scripts/package-app.sh 0.9.8 to regenerate the zip and sha256 -gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-0.9.8.zip --clobber -gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-0.9.8.zip.sha256 --clobber +# After re-running mac/Scripts/package-app.sh v0.9.8 to regenerate the zip and sha256 +gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-v0.9.8.zip --clobber +gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256 --clobber ``` -The GitHub Release page will now serve the fixed assets. The menubar installer fetches from the Release by tag, so users who run `npx codeburn menubar` after the replacement get the fixed version automatically. +The GitHub Release page will now serve the fixed assets. The menubar installer selects the newest `mac-v*` release with `CodeBurnMenubar-v*.zip` plus its checksum, so users who run `codeburn menubar --force` after the replacement get the fixed version automatically. ## Rollback @@ -245,7 +245,7 @@ git push origin --delete v0.9.8 npm does not allow republishing to the same version. If you must unpublish from npm, use `npm unpublish codeburn@0.9.8 --force` (requires Owner role), but this is discouraged and all users who installed that version retain it. -For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it. Users will see the update pill in the menubar settings and upgrade automatically (or manually via `npx codeburn menubar --force`). +For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it. Users will see the update pill in the menubar settings and upgrade automatically (or manually via `codeburn menubar --force`). ## Summary diff --git a/mac/README.md b/mac/README.md index 3a7f1d77..b12b836d 100644 --- a/mac/README.md +++ b/mac/README.md @@ -6,19 +6,17 @@ Native Swift + SwiftUI menubar app. The codeburn menubar surface. - macOS 14+ (Sonoma) - Swift 6.0+ toolchain (bundled with Xcode 16 or standalone) -- `codeburn` CLI installed globally (`npm install -g codeburn`) or available at a path you pass via `CODEBURN_BIN` +- `codeburn` CLI installed globally (`npm install -g codeburn`) ## Install (end users) One command: ```bash -npx codeburn menubar +codeburn menubar ``` -That's it. The command downloads the latest `.app` from GitHub Releases, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise. - -If you already have the CLI installed globally (`npm install -g codeburn`), `codeburn menubar` works the same way. +That's it. The command records the persistent `codeburn` CLI path, downloads the latest `.app` from the newest `mac-v*` GitHub Release with a matching checksum, verifies it, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise. ### Build from source @@ -39,7 +37,7 @@ cd mac swift build # Point the app at your dev CLI build instead of the globally installed `codeburn`: npm --prefix .. run build -CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run +CODEBURN_ALLOW_DEV_BIN=1 CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run ``` The app registers itself as a menubar accessory (`LSUIElement = true` at runtime). No Dock icon. @@ -48,7 +46,7 @@ The app registers itself as a menubar accessory (`LSUIElement = true` at runtime On launch and every 60 seconds thereafter, the app spawns `codeburn status --format menubar-json --no-optimize` directly (argv, no shell) via `CodeburnCLI.makeProcess` and decodes the JSON into `MenubarPayload`. The manual refresh button in the footer invokes the same command without `--no-optimize`, which includes optimize findings but takes longer. -Override the binary via the `CODEBURN_BIN` environment variable (default: `codeburn` on PATH). The value is validated against a strict allowlist (alphanumerics plus `._/-` space) before use, so a malicious env var can't inject shell commands. +Release installs record a persistent absolute CLI path in `~/Library/Application Support/CodeBurn/codeburn-cli-path.v1`, then fall back to Homebrew's common `codeburn` locations. For development only, set `CODEBURN_ALLOW_DEV_BIN=1` with `CODEBURN_BIN`; the value is validated against a strict allowlist before use, so a malicious env var can't inject shell commands. ## Project layout diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh index 6df7abba..c9982a75 100755 --- a/mac/Scripts/package-app.sh +++ b/mac/Scripts/package-app.sh @@ -87,13 +87,12 @@ cat > "${BUNDLE}/Contents/PkgInfo" <<'PKG' APPL???? PKG -# Ad-hoc sign so macOS treats the bundle as internally consistent. This satisfies the -# minimum bundle-validity checks on macOS 14+ and prevents a class of Gatekeeper edge -# cases on managed Macs. A Developer ID signature (separate setup) would additionally -# surface the publisher name in Finder; not required here. +# Ad-hoc sign so macOS treats the bundle as internally consistent. Release +# notarization can layer a Developer ID signature on top, but this local step +# must still fail closed if signing or verification breaks. echo "▸ Ad-hoc signing..." -codesign --force --sign - --timestamp=none --deep "${BUNDLE}" 2>/dev/null || true -codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)" +codesign --force --sign - --timestamp=none --deep "${BUNDLE}" +codesign --verify --deep --strict "${BUNDLE}" ZIP_NAME="CodeBurnMenubar-${ASSET_VERSION}.zip" ZIP_PATH="${DIST_DIR}/${ZIP_NAME}" diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 94bb1c29..2c287e4e 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -65,6 +65,10 @@ final class AppStore { return Date().timeIntervalSince(last) } + private var todayAllKey: PayloadCacheKey { + PayloadCacheKey(period: .today, provider: .all) + } + private var currentKey: PayloadCacheKey { PayloadCacheKey(period: selectedPeriod, provider: selectedProvider) } @@ -76,7 +80,16 @@ final class AppStore { /// Today (across all providers) is pinned for the always-visible menubar icon, independent of /// the popover's selected period or provider. var todayPayload: MenubarPayload? { - cache[PayloadCacheKey(period: .today, provider: .all)]?.payload + cache[todayAllKey]?.payload + } + + var todayPayloadAgeSeconds: Int? { + guard let cached = cache[todayAllKey] else { return nil } + return Int(Date().timeIntervalSince(cached.fetchedAt)) + } + + var needsStatusPayloadRefresh: Bool { + cache[todayAllKey]?.isFresh != true } /// All-provider payload for the selected period. Used by the tab strip to show @@ -100,14 +113,18 @@ final class AppStore { staleInteractivePayloadAgeSeconds != nil } + var hasMissingInteractivePayloadWithoutAttempt: Bool { + cache[currentKey] == nil && !isCurrentKeyLoading && !hasAttemptedCurrentKeyLoad + } + var shouldResetInteractiveRefreshPipeline: Bool { - hasStaleLoading || hasStaleInteractivePayload + hasStaleLoading || hasStaleInteractivePayload || hasMissingInteractivePayloadWithoutAttempt } var staleInteractivePayloadAgeSeconds: Int? { let keys = Set([ currentKey, - PayloadCacheKey(period: .today, provider: .all), + todayAllKey, PayloadCacheKey(period: selectedPeriod, provider: .all), ]) let staleAges = keys.compactMap { key -> TimeInterval? in @@ -119,10 +136,9 @@ final class AppStore { } var needsInteractivePayloadRefresh: Bool { - let todayKey = PayloadCacheKey(period: .today, provider: .all) let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all) return cache[currentKey]?.isFresh != true || - cache[todayKey]?.isFresh != true || + cache[todayAllKey]?.isFresh != true || cache[periodAllKey]?.isFresh != true || hasStaleLoading } @@ -149,16 +165,7 @@ final class AppStore { /// all-provider data in parallel so tab strip costs stay in sync with the hero. func switchTo(period: Period) { selectedPeriod = period - switchTask?.cancel() - switchTask = Task { - if selectedProvider == .all { - await refresh(includeOptimize: false, force: true) - } else { - async let main: Void = refresh(includeOptimize: false, force: true) - async let all: Void = refreshQuietly(period: period) - _ = await (main, all) - } - } + startInteractiveSelectionRefresh() } /// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only @@ -166,13 +173,21 @@ final class AppStore { /// in parallel so the tab strip costs stay in sync with the hero. func switchTo(provider: ProviderFilter) { selectedProvider = provider + startInteractiveSelectionRefresh() + } + + private func startInteractiveSelectionRefresh() { switchTask?.cancel() + resetLoadingState() + let period = selectedPeriod + let provider = selectedProvider + lastErrorByKey[PayloadCacheKey(period: period, provider: provider)] = nil switchTask = Task { if provider == .all { - await refresh(includeOptimize: false, force: true) + await refresh(includeOptimize: false, force: true, showLoading: true) } else { - async let main: Void = refresh(includeOptimize: false, force: true) - async let all: Void = refreshQuietly(period: selectedPeriod) + async let main: Void = refresh(includeOptimize: false, force: true, showLoading: true) + async let all: Void = refreshQuietly(period: period) _ = await (main, all) } } @@ -239,10 +254,14 @@ final class AppStore { } } - private func invalidateStaleDayCache() { + private func currentCacheDate() -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" - let today = formatter.string(from: Date()) + return formatter.string(from: Date()) + } + + private func invalidateStaleDayCache() { + let today = currentCacheDate() if cacheDate != today { payloadRefreshGeneration &+= 1 cache.removeAll() @@ -266,7 +285,7 @@ final class AppStore { let cacheDateAtStart = cacheDate let generationAtStart = payloadRefreshGeneration if !force, cache[key]?.isFresh == true { return } - if !force, inFlightKeys.contains(key) { return } + if inFlightKeys.contains(key) { return } inFlightKeys.insert(key) attemptedKeys.insert(key) lastErrorByKey[key] = nil @@ -309,7 +328,8 @@ final class AppStore { // fetch, this payload was computed against yesterday's date and // would pollute today's freshly-cleared cache. Drop it; the next // tick will refetch with today's data. - if cacheDate != cacheDateAtStart { + if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { + invalidateStaleDayCache() NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — calendar rolled mid-fetch") return } @@ -324,7 +344,10 @@ final class AppStore { let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false) guard !Task.isCancelled else { return } if generationAtStart != payloadRefreshGeneration { return } - if cacheDate != cacheDateAtStart { return } + if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { + invalidateStaleDayCache() + return + } cache[key] = CachedPayload(payload: fallback, fetchedAt: Date()) lastSuccessByKey[key] = Date() lastErrorByKey[key] = nil @@ -346,10 +369,21 @@ final class AppStore { /// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge). /// Does not toggle isLoading, so the popover's loading overlay is unaffected. /// Always uses the .all provider since the menubar badge shows total spend. - func refreshQuietly(period: Period) async { + func refreshQuietly(period: Period, force: Bool = false) async { invalidateStaleDayCache() + let key = PayloadCacheKey(period: period, provider: .all) + if !force, cache[key]?.isFresh == true { return } + if inFlightKeys.contains(key) { return } + inFlightKeys.insert(key) + attemptedKeys.insert(key) let cacheDateAtStart = cacheDate let generationAtStart = payloadRefreshGeneration + if period == .today, let age = todayPayloadAgeSeconds, age > 120 { + NSLog("CodeBurn: refreshing stale today status payload after %ds", age) + } + defer { + inFlightKeys.remove(key) + } do { let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false) if generationAtStart != payloadRefreshGeneration { @@ -358,8 +392,10 @@ final class AppStore { } // Same day-rollover guard as refresh(): drop yesterday's payload if // the calendar rolled over during the fetch. - if cacheDate != cacheDateAtStart { return } - let key = PayloadCacheKey(period: period, provider: .all) + if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { + invalidateStaleDayCache() + return + } cache[key] = CachedPayload(payload: fresh, fetchedAt: Date()) lastSuccessByKey[key] = Date() lastErrorByKey[key] = nil @@ -582,7 +618,7 @@ final class AppStore { var aggregateQuotaStatus: AggregateQuotaStatus { var providers: [(name: String, percent: Double)] = [] - if case .loaded = subscriptionLoadState, let usage = subscription { + if let usage = subscription, shouldIncludeCachedQuota(loadState: subscriptionLoadState) { let worst = [ usage.fiveHourPercent, usage.sevenDayPercent, @@ -591,7 +627,7 @@ final class AppStore { ].compactMap { $0 }.max() ?? 0 if worst > 0 { providers.append(("Claude", worst)) } } - if case .loaded = codexLoadState, let usage = codexUsage { + if let usage = codexUsage, shouldIncludeCachedQuota(loadState: codexLoadState) { let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0) if worst > 0 { providers.append(("Codex", worst)) } } @@ -602,6 +638,15 @@ final class AppStore { return AggregateQuotaStatus(severity: severity, warnings: warnings) } + private func shouldIncludeCachedQuota(loadState: SubscriptionLoadState) -> Bool { + switch loadState { + case .notBootstrapped, .bootstrapping, .noCredentials: + return false + case .loading, .loaded, .failed, .terminalFailure, .transientFailure: + return true + } + } + func quotaSummary(for filter: ProviderFilter) -> QuotaSummary? { switch filter { case .claude: return claudeQuotaSummary(filter: filter) @@ -799,6 +844,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case claude = "Claude" case codex = "Codex" case cursor = "Cursor" + case cursorAgent = "Cursor Agent" case copilot = "Copilot" case droid = "Droid" case gemini = "Gemini" @@ -812,16 +858,21 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case omp = "OMP" case rooCode = "Roo Code" case crush = "Crush" + case antigravity = "Antigravity" + case goose = "Goose" var id: String { rawValue } var providerKeys: [String] { switch self { - case .cursor: ["cursor", "cursor agent"] + case .cursor: ["cursor"] + case .cursorAgent: ["cursor-agent", "cursor agent"] case .rooCode: ["roo-code", "roo code"] case .kiloCode: ["kilo-code", "kilocode"] case .ibmBob: ["ibm-bob", "ibm bob"] case .openclaw: ["openclaw"] + case .antigravity: ["antigravity"] + case .goose: ["goose"] default: [rawValue.lowercased()] } } @@ -832,6 +883,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .claude: "claude" case .codex: "codex" case .cursor: "cursor" + case .cursorAgent: "cursor-agent" case .copilot: "copilot" case .droid: "droid" case .gemini: "gemini" @@ -845,6 +897,8 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .omp: "omp" case .rooCode: "roo-code" case .crush: "crush" + case .antigravity: "antigravity" + case .goose: "goose" } } } diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 7daccb2a..61915759 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -3,10 +3,9 @@ import AppKit import Observation private let refreshIntervalSeconds: UInt64 = 30 -private let nanosPerSecond: UInt64 = 1_000_000_000 -private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond private let forceRefreshWatchdogSeconds: TimeInterval = 90 private let refreshLoopWatchdogSeconds: TimeInterval = 90 +private let statusPayloadRefreshWatchdogSeconds: TimeInterval = 60 private let refreshRateLimitSeconds: TimeInterval = 5 private let interactiveQuotaRefreshFloorSeconds: TimeInterval = 30 private let statusItemWidth: CGFloat = NSStatusItem.variableLength @@ -38,13 +37,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { /// Held for the lifetime of the app to opt out of App Nap and Automatic Termination. private var backgroundActivity: NSObjectProtocol? private var pendingRefreshWork: DispatchWorkItem? - private var refreshLoopTask: Task? + private var refreshTimer: DispatchSourceTimer? private var forceRefreshTask: Task? private var forceRefreshStartedAt: Date? private var forceRefreshGeneration: UInt64 = 0 + private var statusPayloadRefreshTask: Task? + private var statusPayloadRefreshStartedAt: Date? + private var statusPayloadRefreshGeneration: UInt64 = 0 private var manualRefreshTask: Task? private var manualRefreshGeneration: UInt64 = 0 + private var claudeQuotaRefreshTask: Task? + private var codexQuotaRefreshTask: Task? private var refreshLoopHeartbeatAt: Date = .distantPast + private var lastLaunchAgentHeartbeatAt: Date = .distantPast func applicationWillFinishLaunching(_ notification: Notification) { // Set accessory policy before the app's focus chain forms. On macOS Tahoe @@ -133,11 +138,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { queue: .main ) { [weak self] _ in Task { @MainActor in - self?.recoverRefreshPipelineAfterInterruption(resetLoading: false, reason: "launch agent") + self?.handleLaunchAgentHeartbeat() } } } + private func handleLaunchAgentHeartbeat() { + let now = Date() + guard now.timeIntervalSince(lastLaunchAgentHeartbeatAt) >= refreshRateLimitSeconds else { return } + lastLaunchAgentHeartbeatAt = now + let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt) + guard refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds else { + _ = store.clearStaleLoadingIfNeeded() + _ = clearStaleForceRefreshIfNeeded(now: now) + _ = clearStaleStatusPayloadRefreshIfNeeded(now: now) + return + } + if refreshTimer != nil { + NSLog("CodeBurn: refresh loop stale for %ds after launch agent - restarting", Int(loopAge)) + } + startRefreshLoop(forceQuotaOnStart: false) + } + private func prepareRefreshPipelineForSleep() { forceRefreshTask?.cancel() forceRefreshTask = nil @@ -146,9 +168,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { manualRefreshTask?.cancel() manualRefreshTask = nil manualRefreshGeneration &+= 1 + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 store.resetLoadingState() - refreshLoopTask?.cancel() - refreshLoopTask = nil + stopRefreshTimer() refreshLoopHeartbeatAt = .distantPast lastRefreshTime = .distantPast } @@ -162,21 +187,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { manualRefreshTask?.cancel() manualRefreshTask = nil manualRefreshGeneration &+= 1 + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 store.resetRefreshState(clearCache: clearCache) } else { _ = store.clearStaleLoadingIfNeeded() } let now = Date() let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt) - if refreshLoopTask == nil || loopAge > refreshLoopWatchdogSeconds { - if refreshLoopTask != nil { - NSLog("CodeBurn: refresh loop stale for %ds after %@ — restarting", Int(loopAge), reason) + if refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds { + if refreshTimer != nil { + NSLog("CodeBurn: refresh loop stale for %ds after %@ - restarting", Int(loopAge), reason) } - refreshLoopTask?.cancel() - refreshLoopTask = nil - startRefreshLoop() + startRefreshLoop(forceQuotaOnStart: false) + } else { + runRefreshLoopTick(reason: reason, forcePayload: true, forceQuota: false) } - forceRefresh(bypassRateLimit: true, forceQuota: true) } private func installLaunchAgentIfNeeded() { @@ -236,7 +264,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { guard !UserDefaults.standard.bool(forKey: key) else { return } let appPath = Bundle.main.bundlePath - let script = "tell application \"System Events\" to make login item at end with properties {path:\"\(appPath)\", hidden:false}" + let script = "tell application \"System Events\" to make login item at end with properties {path:\(appleScriptStringLiteral(appPath)), hidden:false}" let process = Process() process.launchPath = "/usr/bin/osascript" @@ -255,14 +283,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } + private func appleScriptStringLiteral(_ value: String) -> String { + var escaped = value.replacingOccurrences(of: "\\", with: "\\\\") + escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"") + escaped = escaped.replacingOccurrences(of: "\r", with: "") + escaped = escaped.replacingOccurrences(of: "\n", with: "") + return "\"\(escaped)\"" + } + private var lastRefreshTime: Date = .distantPast @discardableResult private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool { - if let started = forceRefreshStartedAt, forceRefreshTask != nil { + if forceRefreshTask != nil { + guard let started = forceRefreshStartedAt else { + NSLog("CodeBurn: force refresh task had no start timestamp - clearing") + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshGeneration &+= 1 + store.resetLoadingState() + return true + } let elapsed = now.timeIntervalSince(started) guard elapsed > forceRefreshWatchdogSeconds else { return false } - NSLog("CodeBurn: force refresh stuck for %ds — cancelling and restarting", Int(elapsed)) + NSLog("CodeBurn: force refresh stuck for %ds - cancelling and restarting", Int(elapsed)) forceRefreshTask?.cancel() forceRefreshTask = nil forceRefreshStartedAt = nil @@ -273,9 +317,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { return false } + @discardableResult + private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool { + if statusPayloadRefreshTask != nil { + guard let started = statusPayloadRefreshStartedAt else { + NSLog("CodeBurn: today status refresh task had no start timestamp - clearing") + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshGeneration &+= 1 + return true + } + let elapsed = now.timeIntervalSince(started) + guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false } + NSLog("CodeBurn: today status refresh stuck for %ds - cancelling", Int(elapsed)) + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 + return true + } + return false + } + + private func refreshTodayStatusPayloadIfNeeded(reason: String, force: Bool = false) { + let now = Date() + _ = clearStaleStatusPayloadRefreshIfNeeded(now: now) + guard statusPayloadRefreshTask == nil else { return } + guard force || store.needsStatusPayloadRefresh else { return } + + if let age = store.todayPayloadAgeSeconds, age > 120 { + NSLog("CodeBurn: today status payload stale for %ds on %@ refresh", age, reason) + } + + statusPayloadRefreshStartedAt = now + statusPayloadRefreshGeneration &+= 1 + let generation = statusPayloadRefreshGeneration + statusPayloadRefreshTask = Task { [weak self] in + guard let self else { return } + await self.store.refreshQuietly(period: .today, force: true) + self.refreshStatusButton() + guard self.statusPayloadRefreshGeneration == generation, !Task.isCancelled else { return } + self.statusPayloadRefreshTask = nil + self.statusPayloadRefreshStartedAt = nil + } + } + private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) { let now = Date() _ = clearStaleForceRefreshIfNeeded(now: now) + if forceRefreshTask != nil { + refreshTodayStatusPayloadIfNeeded(reason: "blocked force refresh") + } guard forceRefreshTask == nil else { return } if !bypassRateLimit { guard now.timeIntervalSince(lastRefreshTime) > refreshRateLimitSeconds else { return } @@ -341,16 +433,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { switch (shouldRefreshClaude, shouldRefreshCodex) { case (true, true): - async let claude = store.refreshSubscriptionReportingSuccess() - async let codex = store.refreshCodexReportingSuccess() + async let claude = refreshClaudeQuotaSingleFlight() + async let codex = refreshCodexQuotaSingleFlight() if await claude { lastSubscriptionRefreshAt = Date() } if await codex { lastCodexRefreshAt = Date() } case (true, false): - if await store.refreshSubscriptionReportingSuccess() { + if await refreshClaudeQuotaSingleFlight() { lastSubscriptionRefreshAt = Date() } case (false, true): - if await store.refreshCodexReportingSuccess() { + if await refreshCodexQuotaSingleFlight() { lastCodexRefreshAt = Date() } case (false, false): @@ -359,6 +451,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { return true } + private func refreshClaudeQuotaSingleFlight() async -> Bool { + if let task = claudeQuotaRefreshTask { + return await task.value + } + let task = Task { [store] in + await store.refreshSubscriptionReportingSuccess() + } + claudeQuotaRefreshTask = task + let result = await task.value + if claudeQuotaRefreshTask != nil { + claudeQuotaRefreshTask = nil + } + return result + } + + private func refreshCodexQuotaSingleFlight() async -> Bool { + if let task = codexQuotaRefreshTask { + return await task.value + } + let task = Task { [store] in + await store.refreshCodexReportingSuccess() + } + codexQuotaRefreshTask = task + let result = await task.value + if codexQuotaRefreshTask != nil { + codexQuotaRefreshTask = nil + } + return result + } + private func refreshLiveQuotaProgressForPopoverOpen() { let now = Date() let claudeElapsed = now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) @@ -384,23 +506,53 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { ) } - private func startRefreshLoop() { - refreshLoopTask?.cancel() + private func stopRefreshTimer() { + refreshTimer?.setEventHandler {} + refreshTimer?.cancel() + refreshTimer = nil + } + + private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) { refreshLoopHeartbeatAt = Date() - forceRefresh(bypassRateLimit: true, forceQuota: true) - refreshLoopTask = Task { [weak self] in - while !Task.isCancelled { - guard let self else { return } - self.refreshLoopHeartbeatAt = Date() - let clearedStaleForceRefresh = self.clearStaleForceRefreshIfNeeded() - let clearedStaleLoading = self.store.clearStaleLoadingIfNeeded() - let sinceLast = Date().timeIntervalSince(self.lastRefreshTime) - if clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= TimeInterval(refreshIntervalSeconds) { - self.forceRefresh(bypassRateLimit: true) - } - try? await Task.sleep(nanoseconds: refreshIntervalNanos) + let hadForceRefreshInFlight = forceRefreshTask != nil + let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded() + let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded() + let clearedStaleLoading = store.clearStaleLoadingIfNeeded() + let statusPayloadStale = store.needsStatusPayloadRefresh + let sinceLast = Date().timeIntervalSince(lastRefreshTime) + let shouldForceRefresh = forcePayload || + clearedStaleForceRefresh || + clearedStaleLoading || + sinceLast >= TimeInterval(refreshIntervalSeconds) + + if shouldForceRefresh { + forceRefresh(bypassRateLimit: true, forceQuota: forceQuota) + } + + let forceRefreshWasBlocked = hadForceRefreshInFlight && forceRefreshTask != nil + if statusPayloadStale && (!shouldForceRefresh || forceRefreshWasBlocked || clearedStaleStatusRefresh) { + refreshTodayStatusPayloadIfNeeded(reason: reason, force: forcePayload) + } + } + + private func startRefreshLoop(forceQuotaOnStart: Bool = false) { + stopRefreshTimer() + runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: forceQuotaOnStart) + + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule( + deadline: .now() + .seconds(Int(refreshIntervalSeconds)), + repeating: .seconds(Int(refreshIntervalSeconds)), + leeway: .seconds(2) + ) + timer.setEventHandler { [weak self] in + Task { @MainActor [weak self] in + self?.runRefreshLoopTick(reason: "timer") } } + refreshTimer = timer + refreshLoopHeartbeatAt = Date() + timer.resume() } @MainActor @@ -412,10 +564,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { forceRefreshTask = nil forceRefreshStartedAt = nil forceRefreshGeneration &+= 1 + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 pendingRefreshWork?.cancel() pendingRefreshWork = nil - refreshLoopTask?.cancel() - refreshLoopTask = nil + stopRefreshTimer() store.resetRefreshState(clearCache: true) lastRefreshTime = .distantPast refreshStatusButton() @@ -429,7 +584,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { async let payload: Void = self.store.refresh(includeOptimize: false, force: true, showLoading: true) async let quotas: Bool = self.refreshLiveQuotaProgressIfDue(force: true) if needsTodayTotal { - await self.store.refreshQuietly(period: .today) + await self.store.refreshQuietly(period: .today, force: true) } _ = await payload guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } @@ -438,7 +593,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { _ = await quotas guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } self.manualRefreshTask = nil - if self.refreshLoopTask == nil { + if self.refreshTimer == nil { self.startRefreshLoop() } } @@ -725,14 +880,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { await updateChecker.check() let alert = NSAlert() alert.icon = codeburnAlertIcon() - if updateChecker.updateAvailable, let latest = updateChecker.latestVersion { + if let error = updateChecker.updateError { + alert.messageText = "Update Check Failed" + alert.informativeText = error + alert.alertStyle = .warning + } else if updateChecker.updateAvailable, let latest = updateChecker.latestVersion { alert.messageText = "Update Available" alert.informativeText = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion))). Run:\n\ncodeburn menubar --force" + alert.alertStyle = .informational } else { alert.messageText = "Up to Date" alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))." + alert.alertStyle = .informational } - alert.alertStyle = .informational alert.addButton(withTitle: "OK") alert.runModal() } diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift index 9d887bfa..e47db7b2 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift @@ -36,15 +36,11 @@ enum ClaudeCredentialStore { private static let credentialsRelativePath = ".claude/.credentials.json" private static let maxCredentialBytes = 64 * 1024 - /// Local cache file. Stored under Application Support with 0600 permissions - /// so only the current user can read it. We deliberately do NOT use the - /// macOS Keychain for our own cache: keychain ACLs are bound to the binary - /// code signature, so reading our own item triggers a prompt every time the - /// binary changes (debug rebuilds, app updates with re-signing). Putting the - /// cache in a plain file means the only Keychain prompt our user ever sees - /// is the initial Connect read of Claude Code's own keychain entry. - /// Threat model: same as ~/.claude/.credentials.json (also plaintext). + /// Legacy local cache file. New writes use the macOS Keychain; this path is + /// read once for migration and then removed. private static let cacheFilename = "claude-credentials.v1.json" + private static let ourKeychainService = "org.agentseal.codeburn.menubar.claude.oauth.v1" + private static let ourKeychainAccount = "default" private static let lock = NSLock() private nonisolated(unsafe) static var memoryCache: CachedRecord? @@ -283,6 +279,10 @@ enum ClaudeCredentialStore { } private static func readOurCache() throws -> CredentialRecord? { + if let record = try readOurKeychainCache() { + return record + } + let url = cacheFileURL() guard FileManager.default.fileExists(atPath: url.path) else { return nil } // Route through SafeFile.read so we lstat for symlinks before opening @@ -291,21 +291,66 @@ enum ClaudeCredentialStore { // CodeBurn/ between disconnect and reconnect could redirect our read // to /dev/zero (unbounded memory) or another file the user owns. let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) - return try? JSONDecoder().decode(CredentialRecord.self, from: data) + guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil } + try? writeOurKeychainCache(record: record) + try? FileManager.default.removeItem(at: url) + return record } private static func writeOurCache(record: CredentialRecord) throws { + try writeOurKeychainCache(record: record) + } + + private static func readOurKeychainCache() throws -> CredentialRecord? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: ourKeychainService, + kSecAttrAccount as String: ourKeychainAccount, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw StoreError.keychainReadFailed(status) + } + return try? JSONDecoder().decode(CredentialRecord.self, from: data) + } + + private static func writeOurKeychainCache(record: CredentialRecord) throws { let url = cacheFileURL() let data = try JSONEncoder().encode(record) - // SafeFile.write opens the temp file with O_CREAT | O_EXCL | O_NOFOLLOW - // and the explicit 0600 mode in a single syscall — no race window - // where the file briefly exists at default umask, and no chance of - // following a malicious symlink at the destination path. Also creates - // the parent dir at 0700. - try SafeFile.write(data, to: url.path, mode: 0o600) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: ourKeychainService, + kSecAttrAccount as String: ourKeychainAccount, + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var add = query + add.merge(attributes) { _, new in new } + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw StoreError.keychainWriteFailed(addStatus) + } + } else if status != errSecSuccess { + throw StoreError.keychainWriteFailed(status) + } + try? FileManager.default.removeItem(at: url) } private static func deleteOurCache() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: ourKeychainService, + kSecAttrAccount as String: ourKeychainAccount, + ] + SecItemDelete(query as CFDictionary) try? FileManager.default.removeItem(at: cacheFileURL()) } diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift index d821151d..cffae7bf 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift @@ -1,4 +1,5 @@ import Foundation +import Security /// Owns the Codex (ChatGPT-mode) OAuth credential lifecycle. Mirrors /// ClaudeCredentialStore but reads from ~/.codex/auth.json — Codex CLI @@ -17,6 +18,8 @@ enum CodexCredentialStore { private static let maxCredentialBytes = 64 * 1024 private static let cacheFilename = "codex-credentials.v1.json" + private static let ourKeychainService = "org.agentseal.codeburn.menubar.codex.oauth.v1" + private static let ourKeychainAccount = "default" private static let lock = NSLock() private nonisolated(unsafe) static var memoryCache: CachedRecord? @@ -198,28 +201,74 @@ enum CodexCredentialStore { } private static func readOurCache() throws -> CredentialRecord? { + if let record = try readOurKeychainCache() { + return record + } + let url = cacheFileURL() guard FileManager.default.fileExists(atPath: url.path) else { return nil } // Symlink-defense + size cap (same hardening as ClaudeCredentialStore). let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) - return try? JSONDecoder().decode(CredentialRecord.self, from: data) + guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil } + try? writeOurKeychainCache(record: record) + try? FileManager.default.removeItem(at: url) + return record } private static func writeOurCache(record: CredentialRecord) throws { + try writeOurKeychainCache(record: record) + } + + private static func readOurKeychainCache() throws -> CredentialRecord? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: ourKeychainService, + kSecAttrAccount as String: ourKeychainAccount, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw StoreError.fileWriteFailed("keychain read failed with status \(status)") + } + return try? JSONDecoder().decode(CredentialRecord.self, from: data) + } + + private static func writeOurKeychainCache(record: CredentialRecord) throws { let url = cacheFileURL() let data = try JSONEncoder().encode(record) - do { - // SafeFile.write opens the temp file with O_CREAT | O_EXCL | O_NOFOLLOW - // and the explicit 0600 mode in a single syscall — no race window - // where the file briefly exists at default umask, and no chance of - // following a malicious symlink at the destination path. - try SafeFile.write(data, to: url.path, mode: 0o600) - } catch { - throw StoreError.fileWriteFailed(String(describing: error)) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: ourKeychainService, + kSecAttrAccount as String: ourKeychainAccount, + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var add = query + add.merge(attributes) { _, new in new } + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw StoreError.fileWriteFailed("keychain write failed with status \(addStatus)") + } + } else if status != errSecSuccess { + throw StoreError.fileWriteFailed("keychain update failed with status \(status)") } + try? FileManager.default.removeItem(at: url) } private static func deleteOurCache() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: ourKeychainService, + kSecAttrAccount as String: ourKeychainAccount, + ] + SecItemDelete(query as CFDictionary) try? FileManager.default.removeItem(at: cacheFileURL()) } diff --git a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift index 955718f2..5441794e 100644 --- a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift +++ b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift @@ -1,10 +1,28 @@ import Foundation import Observation -private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases/latest" +private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20" private let checkIntervalSeconds: TimeInterval = 2 * 24 * 60 * 60 private let lastCheckKey = "UpdateChecker.lastCheckDate" private let cachedVersionKey = "UpdateChecker.latestVersion" +private let updateTimeoutSeconds: UInt64 = 120 +private let maxUpdateStderrBytes = 64 * 1024 + +private final class LockedDataBuffer: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func append(_ chunk: Data, limit: Int) { + lock.withLock { + guard data.count < limit else { return } + data.append(Data(chunk.prefix(limit - data.count))) + } + } + + func snapshot() -> Data { + lock.withLock { data } + } +} @MainActor @Observable @@ -37,19 +55,24 @@ final class UpdateChecker { } func check() async { + updateError = nil guard let url = URL(string: releasesAPI) else { return } var request = URLRequest(url: url) request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent") request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") do { - let (data, _) = try await URLSession.shared.data(for: request) - let release = try JSONDecoder().decode(GitHubRelease.self, from: data) - guard let asset = release.assets.first(where: { - $0.name.hasPrefix("CodeBurnMenubar-v") && $0.name.hasSuffix(".zip") - }) else { return } + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw UpdateCheckError.http(status) + } + let releases = try JSONDecoder().decode([GitHubRelease].self, from: data) + guard let resolved = Self.resolveLatestMenubarRelease(in: releases) else { + throw UpdateCheckError.missingMenubarAsset + } - let version = asset.name + let version = resolved.asset.name .replacingOccurrences(of: "CodeBurnMenubar-", with: "") .replacingOccurrences(of: ".zip", with: "") @@ -57,22 +80,50 @@ final class UpdateChecker { UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastCheckKey) UserDefaults.standard.set(version, forKey: cachedVersionKey) } catch { + updateError = "Update check failed: \(error.localizedDescription)" NSLog("CodeBurn: update check failed: \(error)") } } + 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: { + $0.name.hasPrefix("CodeBurnMenubar-v") && $0.name.hasSuffix(".zip") + }) else { continue } + guard release.assets.contains(where: { $0.name == "\(asset.name).sha256" }) else { continue } + return (release, asset) + } + return nil + } + func performUpdate() { isUpdating = true updateError = nil let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"]) let errPipe = Pipe() + let errBuffer = LockedDataBuffer() process.standardOutput = FileHandle.nullDevice process.standardError = errPipe + errPipe.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + errBuffer.append(chunk, limit: maxUpdateStderrBytes) + } + + let timeoutTask = Task.detached(priority: .utility) { + try? await Task.sleep(nanoseconds: updateTimeoutSeconds * 1_000_000_000) + if process.isRunning { + NSLog("CodeBurn: update subprocess timed out after %llus - terminating", updateTimeoutSeconds) + process.terminate() + } + } process.terminationHandler = { [weak self] proc in - let errData = errPipe.fileHandleForReading.readDataToEndOfFile() - let stderr = String(data: errData, encoding: .utf8) ?? "" + timeoutTask.cancel() + errPipe.fileHandleForReading.readabilityHandler = nil + let stderrData = errBuffer.snapshot() + let stderr = Self.sanitizeForDisplay(String(data: stderrData, encoding: .utf8) ?? "") Task { @MainActor in guard let self else { return } self.isUpdating = false @@ -93,14 +144,41 @@ final class UpdateChecker { NSLog("CodeBurn: update spawn failed: \(error)") } } + + nonisolated private static func sanitizeForDisplay(_ value: String) -> String { + var cleaned = value.replacingOccurrences(of: "\u{0000}", with: "") + let patterns: [(String, String)] = [ + (#"sk-ant-[A-Za-z0-9_-]+"#, "sk-ant-***"), + (#"sk-[A-Za-z0-9_-]{16,}"#, "sk-***"), + (#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#, "eyJ***"), + (#"(?i)Bearer\s+\S+"#, "Bearer ***"), + ] + for (pattern, replacement) in patterns { + cleaned = cleaned.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression) + } + if cleaned.count > 1_000 { cleaned = String(cleaned.prefix(1_000)) + "..." } + return cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +enum UpdateCheckError: LocalizedError { + case http(Int) + case missingMenubarAsset + + var errorDescription: String? { + switch self { + case let .http(status): "GitHub returned HTTP \(status)." + case .missingMenubarAsset: "No mac-v release with a menubar zip and checksum was found." + } + } } -private struct GitHubRelease: Decodable { +struct GitHubRelease: Decodable { let tag_name: String let assets: [GitHubAsset] } -private struct GitHubAsset: Decodable { +struct GitHubAsset: Decodable { let name: String let browser_download_url: String } diff --git a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift index 4f4a5f82..83251de4 100644 --- a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift +++ b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift @@ -13,20 +13,50 @@ enum CodeburnCLI { /// PATH additions for GUI-launched apps, which otherwise get a minimal PATH that misses /// Homebrew and npm global installs. private static let additionalPathEntries = ["/opt/homebrew/bin", "/usr/local/bin"] + private static let persistedPathFilename = "codeburn-cli-path.v1" /// Returns the argv that launches the CLI. Dev override via `CODEBURN_BIN` is honoured only /// if every whitespace-delimited token passes `safeArgPattern`. Otherwise falls back to the /// plain `codeburn` name (resolved via PATH). static func baseArgv() -> [String] { - guard let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], !raw.isEmpty else { - return ["codeburn"] + if ProcessInfo.processInfo.environment["CODEBURN_ALLOW_DEV_BIN"] == "1", + let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], + !raw.isEmpty + { + let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + guard parts.allSatisfy(isSafe) else { + NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using installed codeburn") + return installedArgv() + } + return parts } - let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init) - guard parts.allSatisfy(isSafe) else { - NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using default 'codeburn'") - return ["codeburn"] + + return installedArgv() + } + + private static func installedArgv() -> [String] { + if let persisted = persistedCLIPath(), isSafe(persisted), FileManager.default.isExecutableFile(atPath: persisted) { + return [persisted] } - return parts + for candidate in additionalPathEntries.map({ "\($0)/codeburn" }) { + if FileManager.default.isExecutableFile(atPath: candidate) { + return [candidate] + } + } + return ["codeburn"] + } + + private static func persistedCLIPath() -> String? { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + let url = support + .appendingPathComponent("CodeBurn", isDirectory: true) + .appendingPathComponent(persistedPathFilename) + guard let value = try? String(contentsOf: url, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty, + value.hasPrefix("/") + else { return nil } + return value } /// Builds a `Process` that runs the CLI with the given subcommand args. Uses `/usr/bin/env` diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index df47c460..99b31aab 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -342,6 +342,7 @@ extension ProviderFilter { case .claude: return Theme.categoricalClaude case .codex: return Theme.categoricalCodex case .cursor: return Theme.categoricalCursor + case .cursorAgent: return Color(red: 0x4E/255.0, green: 0xC9/255.0, blue: 0xB0/255.0) case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0) case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0) case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0) @@ -355,6 +356,8 @@ extension ProviderFilter { case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0) case .rooCode: return Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0) case .crush: return Color(red: 0xE0/255.0, green: 0x6C/255.0, blue: 0x9F/255.0) + case .antigravity: return Color(red: 0xFF/255.0, green: 0x7A/255.0, blue: 0x45/255.0) + case .goose: return Color(red: 0xB7/255.0, green: 0x8D/255.0, blue: 0x52/255.0) } } } diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 6a38b1cd..7bad14b3 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -279,7 +279,7 @@ private struct Header: View { .foregroundStyle(.secondary) } Spacer() - if updateChecker.updateAvailable { + if updateChecker.updateAvailable || updateChecker.updateError != nil { UpdateBadge() } AccentPicker() @@ -409,18 +409,25 @@ private struct UpdateBadge: View { var body: some View { Button { - updateChecker.performUpdate() + if updateChecker.updateAvailable { + updateChecker.performUpdate() + } else { + Task { await updateChecker.check() } + } } label: { HStack(spacing: 4) { if updateChecker.isUpdating { ProgressView() .controlSize(.mini) .scaleEffect(0.7) + } else if updateChecker.updateError != nil { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) } else { Image(systemName: "arrow.down.circle.fill") .font(.system(size: 10)) } - Text(updateChecker.isUpdating ? "Updating..." : "Update") + Text(updateChecker.isUpdating ? "Updating..." : (updateChecker.updateError == nil ? "Update" : "Failed")) .font(.system(size: 10, weight: .medium)) } .padding(.horizontal, 8) @@ -430,6 +437,7 @@ private struct UpdateBadge: View { .tint(Theme.brandAccent) .controlSize(.mini) .disabled(updateChecker.isUpdating) + .help(updateChecker.updateError ?? "Install the latest menubar build") } } @@ -537,12 +545,7 @@ struct FooterBar: View { .fixedSize() Button { - // showLoading: true is safe now that the overlay condition uses - // `!hasCachedData` instead of `isLoading`. The button icon swaps - // to the spinner glyph (driven by store.isLoading), giving the - // user visible feedback the click was registered, but the - // popover body keeps the existing data instead of blanking out. - Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } + refreshNow() } label: { Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise") .font(.system(size: 11, weight: .medium)) @@ -588,6 +591,14 @@ struct FooterBar: View { TerminalLauncher.open(subcommand: ["report"]) } + private func refreshNow() { + if let delegate = NSApp.delegate as? AppDelegate { + delegate.refreshSubscriptionNow() + } else { + Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } + } + } + private enum ExportFormat { case csv, json var cliName: String { self == .csv ? "csv" : "json" } diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift index 0c7fb145..fd75fec3 100644 --- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -38,6 +38,7 @@ struct AppStoreRefreshRecoveryTests { #expect(store.todayPayload?.current.cost == 92.33) #expect(store.needsInteractivePayloadRefresh) + #expect(store.needsStatusPayloadRefresh) #expect(store.hasStaleInteractivePayload) #expect(store.shouldResetInteractiveRefreshPipeline) @@ -57,7 +58,27 @@ struct AppStoreRefreshRecoveryTests { ) #expect(!store.needsInteractivePayloadRefresh) + #expect(!store.needsStatusPayloadRefresh) #expect(!store.hasStaleInteractivePayload) #expect(!store.shouldResetInteractiveRefreshPipeline) } + + @Test("missing today status payload needs status refresh") + func missingTodayStatusPayloadNeedsStatusRefresh() { + let store = AppStore() + + #expect(store.todayPayload == nil) + #expect(store.needsStatusPayloadRefresh) + } + + @Test("missing unattempted payload triggers hard recovery") + func missingUnattemptedPayloadTriggersHardRecovery() { + let store = AppStore() + + #expect(!store.hasCachedData) + #expect(!store.hasAttemptedCurrentKeyLoad) + #expect(store.needsInteractivePayloadRefresh) + #expect(store.hasMissingInteractivePayloadWithoutAttempt) + #expect(store.shouldResetInteractiveRefreshPipeline) + } } diff --git a/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift new file mode 100644 index 00000000..44f52b50 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift @@ -0,0 +1,39 @@ +import Testing +@testable import CodeBurnMenubar + +@Suite("UpdateChecker") +struct UpdateCheckerTests { + @Test("selects newest mac release with zip and checksum") + func selectsNewestMacReleaseWithChecksum() { + let releases = [ + GitHubRelease( + tag_name: "v0.9.9", + assets: [GitHubAsset(name: "codeburn-0.9.9.tgz", browser_download_url: "https://example.test/cli")] + ), + GitHubRelease( + tag_name: "mac-v0.9.8", + assets: [ + GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app"), + GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip.sha256", browser_download_url: "https://example.test/app.sha256"), + ] + ), + ] + + let resolved = UpdateChecker.resolveLatestMenubarRelease(in: releases) + + #expect(resolved?.release.tag_name == "mac-v0.9.8") + #expect(resolved?.asset.name == "CodeBurnMenubar-v0.9.8.zip") + } + + @Test("ignores mac release missing checksum") + func ignoresMacReleaseMissingChecksum() { + let releases = [ + GitHubRelease( + tag_name: "mac-v0.9.8", + assets: [GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app")] + ), + ] + + #expect(UpdateChecker.resolveLatestMenubarRelease(in: releases) == nil) + } +} diff --git a/src/main.ts b/src/main.ts index 4ebfe337..eaa4d0e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -398,10 +398,30 @@ program const periodInfo = getDateRange(opts.period) const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const todayRange: DateRange = { start: todayStart, end: now } + const todayStr = toDateString(todayStart) const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) + const rangeStartStr = toDateString(periodInfo.range.start) + const rangeEndStr = toDateString(periodInfo.range.end) const isAllProviders = pf === 'all' const cache = await hydrateCache() + let todayAllProjects: ProjectSummary[] | null = null + let todayAllDays: ReturnType | null = null + + const getTodayAllProjects = async (): Promise => { + if (!todayAllProjects) { + todayAllProjects = fp(await parseAllSessions(todayRange, 'all')) + } + return todayAllProjects + } + + const getTodayAllDays = async (): Promise> => { + if (!todayAllDays) { + todayAllDays = aggregateProjectsIntoDays(await getTodayAllProjects()) + } + return todayAllDays + } // CURRENT PERIOD DATA // - .all provider: assemble from cache + today (fast) @@ -411,12 +431,11 @@ program let scanRange: DateRange if (isAllProviders) { - // Parse only today's sessions; historical data comes from cache to avoid double-counting - const todayRange: DateRange = { start: todayStart, end: new Date() } - const todayProjects = fp(await parseAllSessions(todayRange, 'all')) - const todayDays = aggregateProjectsIntoDays(todayProjects) - const rangeStartStr = toDateString(periodInfo.range.start) - const rangeEndStr = toDateString(periodInfo.range.end) + // Parse today's all-provider sessions once; historical data comes from cache to avoid + // double-counting. Reusing the same parsed object is important for the menubar path: + // large active sessions can OOM if this command retains multiple near-identical scans. + const todayProjects = await getTodayAllProjects() + const todayDays = await getTodayAllDays() const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr) const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) @@ -437,14 +456,9 @@ program const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName])) const providers: ProviderCost[] = [] if (isAllProviders) { - // Parse only today; historical provider costs come from cache - const todayRangeForProviders: DateRange = { start: todayStart, end: new Date() } - const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all'))) - const rangeStartStr = toDateString(periodInfo.range.start) - const todayStr = toDateString(todayStart) const allDaysForProviders = [ ...getDaysInRange(cache, rangeStartStr, yesterdayStr), - ...todayDaysForProviders.filter(d => d.date === todayStr), + ...(await getTodayAllDays()).filter(d => d.date === todayStr), ] const providerTotals: Record = {} for (const d of allDaysForProviders) { @@ -471,11 +485,7 @@ program // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost). const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)) const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr) - // Parse only today for history; historical days come from cache - const todayRangeForHistory: DateRange = { start: todayStart, end: new Date() } - const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForHistory, 'all'))) - const todayStrForHistory = toDateString(todayStart) - const fullHistory = [...allCacheDays, ...allTodayDaysForHistory.filter(d => d.date === todayStrForHistory)] + const fullHistory = [...allCacheDays, ...(await getTodayAllDays()).filter(d => d.date === todayStr)] const dailyHistory = fullHistory.map(d => { if (isAllProviders) { const topModels = Object.entries(d.models) diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts index 051c12cd..915aefaf 100644 --- a/src/menubar-installer.ts +++ b/src/menubar-installer.ts @@ -1,26 +1,29 @@ import { spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { createWriteStream } from 'node:fs' -import { mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { homedir, platform, tmpdir } from 'node:os' import { join } from 'node:path' import { pipeline } from 'node:stream/promises' import { Readable } from 'node:stream' -/// Public GitHub repo that hosts signed macOS release builds. `/releases/latest` returns the -/// newest tagged release; we filter its assets list for our zipped .app bundle. -const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases/latest' +/// Public GitHub repo that hosts macOS release builds. CLI and menubar releases share +/// the repository, so we scan recent releases and choose the newest `mac-v*` release +/// that actually contains the menubar zip. +const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20' const APP_BUNDLE_NAME = 'CodeBurnMenubar.app' +const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar' const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/ const APP_PROCESS_NAME = 'CodeBurnMenubar' const SUPPORTED_OS = 'darwin' const MIN_MACOS_MAJOR = 14 +const PERSISTED_CLI_PATH = join(homedir(), 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1') export type InstallResult = { installedPath: string; launched: boolean } export type ReleaseAsset = { name: string; browser_download_url: string } export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] } -export type ResolvedAssets = { zip: ReleaseAsset; checksum: ReleaseAsset | null } +export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset } export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedAssets { const zip = release.assets.find(a => VERSIONED_ASSET_PATTERN.test(a.name)) @@ -30,8 +33,23 @@ export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedA `Check https://github.com/getagentseal/codeburn/releases.` ) } - const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`) ?? null - return { zip, checksum } + const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`) + if (!checksum) { + throw new Error(`Missing checksum asset ${zip.name}.sha256 in release ${release.tag_name}.`) + } + return { release, zip, checksum } +} + +export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[]): ResolvedAssets { + for (const release of releases) { + if (!release.tag_name.startsWith('mac-v')) continue + try { + return resolveMenubarReleaseAssets(release) + } catch { + continue + } + } + throw new Error('No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.') } function userApplicationsDir(): string { @@ -81,8 +99,8 @@ async function fetchLatestReleaseAssets(): Promise { if (!response.ok) { throw new Error(`GitHub release lookup failed: HTTP ${response.status}`) } - const body = await response.json() as ReleaseResponse - return resolveMenubarReleaseAssets(body) + const body = await response.json() as ReleaseResponse[] + return resolveLatestMenubarReleaseAssets(body) } async function verifyChecksum(archivePath: string, checksumUrl: string): Promise { @@ -131,6 +149,57 @@ async function runCommand(command: string, args: string[]): Promise { }) } +async function captureCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let out = '' + let err = '' + proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() }) + proc.stderr.on('data', (chunk: Buffer) => { err += chunk.toString() }) + proc.on('error', reject) + proc.on('close', (code) => { + if (code === 0) resolve(out.trim()) + else reject(new Error(`${command} exited with status ${code}${err ? `: ${err.trim()}` : ''}`)) + }) + }) +} + +async function verifyBundleIdentity(appPath: string): Promise { + const bundleID = await captureCommand('/usr/libexec/PlistBuddy', [ + '-c', + 'Print :CFBundleIdentifier', + join(appPath, 'Contents', 'Info.plist'), + ]) + if (bundleID !== EXPECTED_BUNDLE_ID) { + throw new Error(`Unexpected menubar bundle id ${bundleID}; expected ${EXPECTED_BUNDLE_ID}.`) + } + await runCommand('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath]) +} + +async function resolvePersistentCodeburnPath(): Promise { + const path = await captureCommand('/usr/bin/env', [ + 'PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin', + 'which', + 'codeburn', + ]) + if (!path.startsWith('/')) { + throw new Error('Resolved codeburn path is not absolute.') + } + if (path.includes('/_npx/') || path.includes('/.npm/_npx/')) { + throw new Error( + 'The menubar app needs a persistent codeburn command. Install CodeBurn globally first: npm install -g codeburn' + ) + } + return path +} + +async function persistCodeburnPath(): Promise { + const cliPath = await resolvePersistentCodeburnPath() + await mkdir(join(homedir(), 'Library', 'Application Support', 'CodeBurn'), { recursive: true, mode: 0o700 }) + await writeFile(PERSISTED_CLI_PATH, `${cliPath}\n`, { mode: 0o600 }) + await chmod(PERSISTED_CLI_PATH, 0o600) +} + async function isAppRunning(): Promise { return new Promise((resolve) => { const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME]) @@ -153,6 +222,7 @@ async function killRunningApp(): Promise { export async function installMenubarApp(options: { force?: boolean } = {}): Promise { await ensureSupportedPlatform() + await persistCodeburnPath() const appsDir = userApplicationsDir() const targetPath = join(appsDir, APP_BUNDLE_NAME) @@ -174,12 +244,8 @@ export async function installMenubarApp(options: { force?: boolean } = {}): Prom console.log(`Downloading ${zip.name}...`) await downloadToFile(zip.browser_download_url, archivePath) - if (checksum) { - console.log('Verifying checksum...') - await verifyChecksum(archivePath, checksum.browser_download_url) - } else { - console.log('Warning: no checksum file found in release, skipping verification.') - } + console.log('Verifying checksum...') + await verifyChecksum(archivePath, checksum.browser_download_url) console.log('Unpacking...') await runCommand('/usr/bin/ditto', ['-x', '-k', archivePath, stagingDir]) @@ -189,6 +255,9 @@ export async function installMenubarApp(options: { force?: boolean } = {}): Prom throw new Error(`Archive did not contain ${APP_BUNDLE_NAME}.`) } + console.log('Verifying app bundle...') + await verifyBundleIdentity(unpackedApp) + // Clear Gatekeeper's quarantine xattr. Without this, the first launch shows the // "cannot verify developer" prompt even for a signed + notarized app when the bundle // was delivered via curl/fetch instead of the Mac App Store. diff --git a/tests/menubar-installer.test.ts b/tests/menubar-installer.test.ts index 44f73cc9..a37cdab2 100644 --- a/tests/menubar-installer.test.ts +++ b/tests/menubar-installer.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { resolveMenubarReleaseAssets, type ReleaseResponse } from '../src/menubar-installer.js' +import { + resolveLatestMenubarReleaseAssets, + resolveMenubarReleaseAssets, + type ReleaseResponse, +} from '../src/menubar-installer.js' function asset(name: string) { return { name, browser_download_url: `https://example.test/${name}` } @@ -34,4 +38,38 @@ describe('resolveMenubarReleaseAssets', () => { expect(() => resolveMenubarReleaseAssets(release)).toThrow(/versioned zip/) }) + + it('fails when the versioned checksum is missing', () => { + const release: ReleaseResponse = { + tag_name: 'mac-v0.9.8', + assets: [ + asset('CodeBurnMenubar-v0.9.8.zip'), + ], + } + + expect(() => resolveMenubarReleaseAssets(release)).toThrow(/Missing checksum/) + }) + + it('selects the newest mac release instead of the newest repo release', () => { + const releases: ReleaseResponse[] = [ + { + tag_name: 'v0.9.9', + assets: [ + asset('codeburn-0.9.9.tgz'), + ], + }, + { + tag_name: 'mac-v0.9.8', + assets: [ + asset('CodeBurnMenubar-v0.9.8.zip'), + asset('CodeBurnMenubar-v0.9.8.zip.sha256'), + ], + }, + ] + + const resolved = resolveLatestMenubarReleaseAssets(releases) + + expect(resolved.release.tag_name).toBe('mac-v0.9.8') + expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.8.zip') + }) })