diff --git a/.github/workflows/release-menubar.yml b/.github/workflows/release-menubar.yml index b2cf949..db41334 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 56e4124..b42d7d8 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 3a7f1d7..b12b836 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 6df7abb..c9982a7 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 20854ae..2c287e4 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -254,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() @@ -324,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 } @@ -339,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 @@ -384,7 +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 } + if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { + invalidateStaleDayCache() + return + } cache[key] = CachedPayload(payload: fresh, fetchedAt: Date()) lastSuccessByKey[key] = Date() lastErrorByKey[key] = nil @@ -607,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, @@ -616,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)) } } @@ -627,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) @@ -824,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" @@ -837,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()] } } @@ -857,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" @@ -870,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 80bc471..6191575 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -46,7 +46,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { 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 @@ -135,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 @@ -181,9 +201,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { if refreshTimer != nil { NSLog("CodeBurn: refresh loop stale for %ds after %@ - restarting", Int(loopAge), reason) } - startRefreshLoop() + startRefreshLoop(forceQuotaOnStart: false) } else { - runRefreshLoopTick(reason: reason, forcePayload: true, forceQuota: true) + runRefreshLoopTick(reason: reason, forcePayload: true, forceQuota: false) } } @@ -244,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" @@ -263,6 +283,14 @@ 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 @@ -405,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): @@ -423,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) @@ -477,9 +535,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } - private func startRefreshLoop() { + private func startRefreshLoop(forceQuotaOnStart: Bool = false) { stopRefreshTimer() - runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: true) + runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: forceQuotaOnStart) let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule( @@ -822,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 9d887bf..e47db7b 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 d821151..cffae7b 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 955718f..5441794 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) + } } -private struct GitHubRelease: Decodable { +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." + } + } +} + +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 4f4a5f8..83251de 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 df47c46..99b31aa 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 6a38b1c..7bad14b 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/UpdateCheckerTests.swift b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift new file mode 100644 index 0000000..44f52b5 --- /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/menubar-installer.ts b/src/menubar-installer.ts index 051c12c..915aefa 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 44f73cc..a37cdab 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') + }) })