Harden menubar refresh and installer

This commit is contained in:
iamtoruk 2026-05-14 18:32:15 -07:00
parent 478131d5b7
commit 909efcf989
15 changed files with 572 additions and 119 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -46,7 +46,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var statusPayloadRefreshGeneration: UInt64 = 0
private var manualRefreshTask: Task<Void, Never>?
private var manualRefreshGeneration: UInt64 = 0
private var claudeQuotaRefreshTask: Task<Bool, Never>?
private var codexQuotaRefreshTask: Task<Bool, Never>?
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()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<ResolvedAssets> {
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<void> {
@ -131,6 +149,57 @@ async function runCommand(command: string, args: string[]): Promise<void> {
})
}
async function captureCommand(command: string, args: string[]): Promise<string> {
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<void> {
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<string> {
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<void> {
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<boolean> {
return new Promise((resolve) => {
const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME])
@ -153,6 +222,7 @@ async function killRunningApp(): Promise<void> {
export async function installMenubarApp(options: { force?: boolean } = {}): Promise<InstallResult> {
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.

View file

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