mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-14 03:29:32 +00:00
Harden menubar refresh and installer
This commit is contained in:
parent
478131d5b7
commit
909efcf989
15 changed files with 572 additions and 119 deletions
16
.github/workflows/release-menubar.yml
vendored
16
.github/workflows/release-menubar.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
24
RELEASING.md
24
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
|
||||
<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
39
mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift
Normal file
39
mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue