mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-26 01:14:56 +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`),
|
# 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,
|
# 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
|
# zips via `ditto`, and uploads the zip to the GitHub Release. The installer verifies
|
||||||
# the download quarantine flag on install so Gatekeeper stays quiet.
|
# the checksum and bundle identity before replacing the local app.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -60,13 +60,15 @@ jobs:
|
||||||
Install with:
|
Install with:
|
||||||
|
|
||||||
```
|
```
|
||||||
npx codeburn menubar
|
npm install -g codeburn
|
||||||
|
codeburn menubar
|
||||||
```
|
```
|
||||||
|
|
||||||
That command drops the app into `~/Applications`, clears the download
|
That command drops the app into `~/Applications`, records the persistent
|
||||||
quarantine, and launches it. If you download the zip from this page directly
|
`codeburn` CLI path used by the menubar, verifies the downloaded checksum,
|
||||||
and macOS shows "cannot verify developer", right-click the app in Finder and
|
clears quarantine after bundle verification, and launches it. If you download
|
||||||
pick Open to whitelist it once.
|
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: |
|
files: |
|
||||||
mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip
|
mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip
|
||||||
mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip.sha256
|
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:
|
The `.github/workflows/release-menubar.yml` workflow automatically detects the `mac-v*` tag and:
|
||||||
|
|
||||||
1. Checks out the repo
|
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)
|
3. Signs the app bundle (ad-hoc signing)
|
||||||
4. Creates a zip file: `CodeBurnMenubar-0.9.8.zip`
|
4. Creates a zip file: `CodeBurnMenubar-v0.9.8.zip`
|
||||||
5. Computes a SHA-256 checksum: `CodeBurnMenubar-0.9.8.zip.sha256`
|
5. Computes a SHA-256 checksum: `CodeBurnMenubar-v0.9.8.zip.sha256`
|
||||||
6. Uploads both to a GitHub Release named "Menubar v0.9.8"
|
6. Uploads both to a GitHub Release named "Menubar v0.9.8"
|
||||||
|
|
||||||
The script output on the build machine shows:
|
The script output on the build machine shows:
|
||||||
|
|
||||||
```
|
```
|
||||||
✓ Built /path/mac/.build/dist/CodeBurnMenubar-0.9.8.zip
|
✓ Built /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip
|
||||||
✓ Checksum /path/mac/.build/dist/CodeBurnMenubar-0.9.8.zip.sha256
|
✓ Checksum /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256
|
||||||
<sha256-hash> CodeBurnMenubar-0.9.8.zip
|
<sha256-hash> CodeBurnMenubar-v0.9.8.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
No manual action is needed; the workflow handles everything.
|
No manual action is needed; the workflow handles everything.
|
||||||
|
|
||||||
### 4. Verify the Release
|
### 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
|
## 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:
|
Use `gh release upload` with the `--clobber` flag to overwrite existing files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# After re-running mac/Scripts/package-app.sh 0.9.8 to regenerate the zip and sha256
|
# 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-0.9.8.zip --clobber
|
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-0.9.8.zip.sha256 --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
|
## 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.
|
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
|
## Summary
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,17 @@ Native Swift + SwiftUI menubar app. The codeburn menubar surface.
|
||||||
|
|
||||||
- macOS 14+ (Sonoma)
|
- macOS 14+ (Sonoma)
|
||||||
- Swift 6.0+ toolchain (bundled with Xcode 16 or standalone)
|
- 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)
|
## Install (end users)
|
||||||
|
|
||||||
One command:
|
One command:
|
||||||
|
|
||||||
```bash
|
```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.
|
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.
|
||||||
|
|
||||||
If you already have the CLI installed globally (`npm install -g codeburn`), `codeburn menubar` works the same way.
|
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
|
|
@ -39,7 +37,7 @@ cd mac
|
||||||
swift build
|
swift build
|
||||||
# Point the app at your dev CLI build instead of the globally installed `codeburn`:
|
# Point the app at your dev CLI build instead of the globally installed `codeburn`:
|
||||||
npm --prefix .. run build
|
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.
|
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.
|
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
|
## Project layout
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,13 +87,12 @@ cat > "${BUNDLE}/Contents/PkgInfo" <<'PKG'
|
||||||
APPL????
|
APPL????
|
||||||
PKG
|
PKG
|
||||||
|
|
||||||
# Ad-hoc sign so macOS treats the bundle as internally consistent. This satisfies the
|
# Ad-hoc sign so macOS treats the bundle as internally consistent. Release
|
||||||
# minimum bundle-validity checks on macOS 14+ and prevents a class of Gatekeeper edge
|
# notarization can layer a Developer ID signature on top, but this local step
|
||||||
# cases on managed Macs. A Developer ID signature (separate setup) would additionally
|
# must still fail closed if signing or verification breaks.
|
||||||
# surface the publisher name in Finder; not required here.
|
|
||||||
echo "▸ Ad-hoc signing..."
|
echo "▸ Ad-hoc signing..."
|
||||||
codesign --force --sign - --timestamp=none --deep "${BUNDLE}" 2>/dev/null || true
|
codesign --force --sign - --timestamp=none --deep "${BUNDLE}"
|
||||||
codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)"
|
codesign --verify --deep --strict "${BUNDLE}"
|
||||||
|
|
||||||
ZIP_NAME="CodeBurnMenubar-${ASSET_VERSION}.zip"
|
ZIP_NAME="CodeBurnMenubar-${ASSET_VERSION}.zip"
|
||||||
ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
|
ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
|
||||||
|
|
|
||||||
|
|
@ -254,10 +254,14 @@ final class AppStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func invalidateStaleDayCache() {
|
private func currentCacheDate() -> String {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
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 {
|
if cacheDate != today {
|
||||||
payloadRefreshGeneration &+= 1
|
payloadRefreshGeneration &+= 1
|
||||||
cache.removeAll()
|
cache.removeAll()
|
||||||
|
|
@ -324,7 +328,8 @@ final class AppStore {
|
||||||
// fetch, this payload was computed against yesterday's date and
|
// fetch, this payload was computed against yesterday's date and
|
||||||
// would pollute today's freshly-cleared cache. Drop it; the next
|
// would pollute today's freshly-cleared cache. Drop it; the next
|
||||||
// tick will refetch with today's data.
|
// 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")
|
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — calendar rolled mid-fetch")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -339,7 +344,10 @@ final class AppStore {
|
||||||
let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false)
|
let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false)
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
if generationAtStart != payloadRefreshGeneration { return }
|
if generationAtStart != payloadRefreshGeneration { return }
|
||||||
if cacheDate != cacheDateAtStart { return }
|
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
||||||
|
invalidateStaleDayCache()
|
||||||
|
return
|
||||||
|
}
|
||||||
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
|
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
|
||||||
lastSuccessByKey[key] = Date()
|
lastSuccessByKey[key] = Date()
|
||||||
lastErrorByKey[key] = nil
|
lastErrorByKey[key] = nil
|
||||||
|
|
@ -384,7 +392,10 @@ final class AppStore {
|
||||||
}
|
}
|
||||||
// Same day-rollover guard as refresh(): drop yesterday's payload if
|
// Same day-rollover guard as refresh(): drop yesterday's payload if
|
||||||
// the calendar rolled over during the fetch.
|
// 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())
|
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
||||||
lastSuccessByKey[key] = Date()
|
lastSuccessByKey[key] = Date()
|
||||||
lastErrorByKey[key] = nil
|
lastErrorByKey[key] = nil
|
||||||
|
|
@ -607,7 +618,7 @@ final class AppStore {
|
||||||
|
|
||||||
var aggregateQuotaStatus: AggregateQuotaStatus {
|
var aggregateQuotaStatus: AggregateQuotaStatus {
|
||||||
var providers: [(name: String, percent: Double)] = []
|
var providers: [(name: String, percent: Double)] = []
|
||||||
if case .loaded = subscriptionLoadState, let usage = subscription {
|
if let usage = subscription, shouldIncludeCachedQuota(loadState: subscriptionLoadState) {
|
||||||
let worst = [
|
let worst = [
|
||||||
usage.fiveHourPercent,
|
usage.fiveHourPercent,
|
||||||
usage.sevenDayPercent,
|
usage.sevenDayPercent,
|
||||||
|
|
@ -616,7 +627,7 @@ final class AppStore {
|
||||||
].compactMap { $0 }.max() ?? 0
|
].compactMap { $0 }.max() ?? 0
|
||||||
if worst > 0 { providers.append(("Claude", worst)) }
|
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)
|
let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0)
|
||||||
if worst > 0 { providers.append(("Codex", worst)) }
|
if worst > 0 { providers.append(("Codex", worst)) }
|
||||||
}
|
}
|
||||||
|
|
@ -627,6 +638,15 @@ final class AppStore {
|
||||||
return AggregateQuotaStatus(severity: severity, warnings: warnings)
|
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? {
|
func quotaSummary(for filter: ProviderFilter) -> QuotaSummary? {
|
||||||
switch filter {
|
switch filter {
|
||||||
case .claude: return claudeQuotaSummary(filter: filter)
|
case .claude: return claudeQuotaSummary(filter: filter)
|
||||||
|
|
@ -824,6 +844,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
||||||
case claude = "Claude"
|
case claude = "Claude"
|
||||||
case codex = "Codex"
|
case codex = "Codex"
|
||||||
case cursor = "Cursor"
|
case cursor = "Cursor"
|
||||||
|
case cursorAgent = "Cursor Agent"
|
||||||
case copilot = "Copilot"
|
case copilot = "Copilot"
|
||||||
case droid = "Droid"
|
case droid = "Droid"
|
||||||
case gemini = "Gemini"
|
case gemini = "Gemini"
|
||||||
|
|
@ -837,16 +858,21 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
||||||
case omp = "OMP"
|
case omp = "OMP"
|
||||||
case rooCode = "Roo Code"
|
case rooCode = "Roo Code"
|
||||||
case crush = "Crush"
|
case crush = "Crush"
|
||||||
|
case antigravity = "Antigravity"
|
||||||
|
case goose = "Goose"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
var providerKeys: [String] {
|
var providerKeys: [String] {
|
||||||
switch self {
|
switch self {
|
||||||
case .cursor: ["cursor", "cursor agent"]
|
case .cursor: ["cursor"]
|
||||||
|
case .cursorAgent: ["cursor-agent", "cursor agent"]
|
||||||
case .rooCode: ["roo-code", "roo code"]
|
case .rooCode: ["roo-code", "roo code"]
|
||||||
case .kiloCode: ["kilo-code", "kilocode"]
|
case .kiloCode: ["kilo-code", "kilocode"]
|
||||||
case .ibmBob: ["ibm-bob", "ibm bob"]
|
case .ibmBob: ["ibm-bob", "ibm bob"]
|
||||||
case .openclaw: ["openclaw"]
|
case .openclaw: ["openclaw"]
|
||||||
|
case .antigravity: ["antigravity"]
|
||||||
|
case .goose: ["goose"]
|
||||||
default: [rawValue.lowercased()]
|
default: [rawValue.lowercased()]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -857,6 +883,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
||||||
case .claude: "claude"
|
case .claude: "claude"
|
||||||
case .codex: "codex"
|
case .codex: "codex"
|
||||||
case .cursor: "cursor"
|
case .cursor: "cursor"
|
||||||
|
case .cursorAgent: "cursor-agent"
|
||||||
case .copilot: "copilot"
|
case .copilot: "copilot"
|
||||||
case .droid: "droid"
|
case .droid: "droid"
|
||||||
case .gemini: "gemini"
|
case .gemini: "gemini"
|
||||||
|
|
@ -870,6 +897,8 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
||||||
case .omp: "omp"
|
case .omp: "omp"
|
||||||
case .rooCode: "roo-code"
|
case .rooCode: "roo-code"
|
||||||
case .crush: "crush"
|
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 statusPayloadRefreshGeneration: UInt64 = 0
|
||||||
private var manualRefreshTask: Task<Void, Never>?
|
private var manualRefreshTask: Task<Void, Never>?
|
||||||
private var manualRefreshGeneration: UInt64 = 0
|
private var manualRefreshGeneration: UInt64 = 0
|
||||||
|
private var claudeQuotaRefreshTask: Task<Bool, Never>?
|
||||||
|
private var codexQuotaRefreshTask: Task<Bool, Never>?
|
||||||
private var refreshLoopHeartbeatAt: Date = .distantPast
|
private var refreshLoopHeartbeatAt: Date = .distantPast
|
||||||
|
private var lastLaunchAgentHeartbeatAt: Date = .distantPast
|
||||||
|
|
||||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||||
// Set accessory policy before the app's focus chain forms. On macOS Tahoe
|
// 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
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
Task { @MainActor 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() {
|
private func prepareRefreshPipelineForSleep() {
|
||||||
forceRefreshTask?.cancel()
|
forceRefreshTask?.cancel()
|
||||||
forceRefreshTask = nil
|
forceRefreshTask = nil
|
||||||
|
|
@ -181,9 +201,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
if refreshTimer != nil {
|
if refreshTimer != nil {
|
||||||
NSLog("CodeBurn: refresh loop stale for %ds after %@ - restarting", Int(loopAge), reason)
|
NSLog("CodeBurn: refresh loop stale for %ds after %@ - restarting", Int(loopAge), reason)
|
||||||
}
|
}
|
||||||
startRefreshLoop()
|
startRefreshLoop(forceQuotaOnStart: false)
|
||||||
} else {
|
} 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 }
|
guard !UserDefaults.standard.bool(forKey: key) else { return }
|
||||||
|
|
||||||
let appPath = Bundle.main.bundlePath
|
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()
|
let process = Process()
|
||||||
process.launchPath = "/usr/bin/osascript"
|
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
|
private var lastRefreshTime: Date = .distantPast
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|
@ -405,16 +433,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
|
|
||||||
switch (shouldRefreshClaude, shouldRefreshCodex) {
|
switch (shouldRefreshClaude, shouldRefreshCodex) {
|
||||||
case (true, true):
|
case (true, true):
|
||||||
async let claude = store.refreshSubscriptionReportingSuccess()
|
async let claude = refreshClaudeQuotaSingleFlight()
|
||||||
async let codex = store.refreshCodexReportingSuccess()
|
async let codex = refreshCodexQuotaSingleFlight()
|
||||||
if await claude { lastSubscriptionRefreshAt = Date() }
|
if await claude { lastSubscriptionRefreshAt = Date() }
|
||||||
if await codex { lastCodexRefreshAt = Date() }
|
if await codex { lastCodexRefreshAt = Date() }
|
||||||
case (true, false):
|
case (true, false):
|
||||||
if await store.refreshSubscriptionReportingSuccess() {
|
if await refreshClaudeQuotaSingleFlight() {
|
||||||
lastSubscriptionRefreshAt = Date()
|
lastSubscriptionRefreshAt = Date()
|
||||||
}
|
}
|
||||||
case (false, true):
|
case (false, true):
|
||||||
if await store.refreshCodexReportingSuccess() {
|
if await refreshCodexQuotaSingleFlight() {
|
||||||
lastCodexRefreshAt = Date()
|
lastCodexRefreshAt = Date()
|
||||||
}
|
}
|
||||||
case (false, false):
|
case (false, false):
|
||||||
|
|
@ -423,6 +451,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
return true
|
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() {
|
private func refreshLiveQuotaProgressForPopoverOpen() {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let claudeElapsed = now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast)
|
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()
|
stopRefreshTimer()
|
||||||
runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: true)
|
runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: forceQuotaOnStart)
|
||||||
|
|
||||||
let timer = DispatchSource.makeTimerSource(queue: .main)
|
let timer = DispatchSource.makeTimerSource(queue: .main)
|
||||||
timer.schedule(
|
timer.schedule(
|
||||||
|
|
@ -822,14 +880,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
await updateChecker.check()
|
await updateChecker.check()
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.icon = codeburnAlertIcon()
|
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.messageText = "Update Available"
|
||||||
alert.informativeText = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion))). Run:\n\ncodeburn menubar --force"
|
alert.informativeText = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion))). Run:\n\ncodeburn menubar --force"
|
||||||
|
alert.alertStyle = .informational
|
||||||
} else {
|
} else {
|
||||||
alert.messageText = "Up to Date"
|
alert.messageText = "Up to Date"
|
||||||
alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))."
|
alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))."
|
||||||
|
alert.alertStyle = .informational
|
||||||
}
|
}
|
||||||
alert.alertStyle = .informational
|
|
||||||
alert.addButton(withTitle: "OK")
|
alert.addButton(withTitle: "OK")
|
||||||
alert.runModal()
|
alert.runModal()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,15 +36,11 @@ enum ClaudeCredentialStore {
|
||||||
private static let credentialsRelativePath = ".claude/.credentials.json"
|
private static let credentialsRelativePath = ".claude/.credentials.json"
|
||||||
private static let maxCredentialBytes = 64 * 1024
|
private static let maxCredentialBytes = 64 * 1024
|
||||||
|
|
||||||
/// Local cache file. Stored under Application Support with 0600 permissions
|
/// Legacy local cache file. New writes use the macOS Keychain; this path is
|
||||||
/// so only the current user can read it. We deliberately do NOT use the
|
/// read once for migration and then removed.
|
||||||
/// 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).
|
|
||||||
private static let cacheFilename = "claude-credentials.v1.json"
|
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 static let lock = NSLock()
|
||||||
private nonisolated(unsafe) static var memoryCache: CachedRecord?
|
private nonisolated(unsafe) static var memoryCache: CachedRecord?
|
||||||
|
|
@ -283,6 +279,10 @@ enum ClaudeCredentialStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readOurCache() throws -> CredentialRecord? {
|
private static func readOurCache() throws -> CredentialRecord? {
|
||||||
|
if let record = try readOurKeychainCache() {
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
let url = cacheFileURL()
|
let url = cacheFileURL()
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||||
// Route through SafeFile.read so we lstat for symlinks before opening
|
// 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
|
// CodeBurn/ between disconnect and reconnect could redirect our read
|
||||||
// to /dev/zero (unbounded memory) or another file the user owns.
|
// to /dev/zero (unbounded memory) or another file the user owns.
|
||||||
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
|
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 {
|
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 url = cacheFileURL()
|
||||||
let data = try JSONEncoder().encode(record)
|
let data = try JSONEncoder().encode(record)
|
||||||
// SafeFile.write opens the temp file with O_CREAT | O_EXCL | O_NOFOLLOW
|
let query: [String: Any] = [
|
||||||
// and the explicit 0600 mode in a single syscall — no race window
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
// where the file briefly exists at default umask, and no chance of
|
kSecAttrService as String: ourKeychainService,
|
||||||
// following a malicious symlink at the destination path. Also creates
|
kSecAttrAccount as String: ourKeychainAccount,
|
||||||
// the parent dir at 0700.
|
]
|
||||||
try SafeFile.write(data, to: url.path, mode: 0o600)
|
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() {
|
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())
|
try? FileManager.default.removeItem(at: cacheFileURL())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
/// Owns the Codex (ChatGPT-mode) OAuth credential lifecycle. Mirrors
|
/// Owns the Codex (ChatGPT-mode) OAuth credential lifecycle. Mirrors
|
||||||
/// ClaudeCredentialStore but reads from ~/.codex/auth.json — Codex CLI
|
/// ClaudeCredentialStore but reads from ~/.codex/auth.json — Codex CLI
|
||||||
|
|
@ -17,6 +18,8 @@ enum CodexCredentialStore {
|
||||||
private static let maxCredentialBytes = 64 * 1024
|
private static let maxCredentialBytes = 64 * 1024
|
||||||
|
|
||||||
private static let cacheFilename = "codex-credentials.v1.json"
|
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 static let lock = NSLock()
|
||||||
private nonisolated(unsafe) static var memoryCache: CachedRecord?
|
private nonisolated(unsafe) static var memoryCache: CachedRecord?
|
||||||
|
|
@ -198,28 +201,74 @@ enum CodexCredentialStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readOurCache() throws -> CredentialRecord? {
|
private static func readOurCache() throws -> CredentialRecord? {
|
||||||
|
if let record = try readOurKeychainCache() {
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
let url = cacheFileURL()
|
let url = cacheFileURL()
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||||
// Symlink-defense + size cap (same hardening as ClaudeCredentialStore).
|
// Symlink-defense + size cap (same hardening as ClaudeCredentialStore).
|
||||||
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
|
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 {
|
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 url = cacheFileURL()
|
||||||
let data = try JSONEncoder().encode(record)
|
let data = try JSONEncoder().encode(record)
|
||||||
do {
|
let query: [String: Any] = [
|
||||||
// SafeFile.write opens the temp file with O_CREAT | O_EXCL | O_NOFOLLOW
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
// and the explicit 0600 mode in a single syscall — no race window
|
kSecAttrService as String: ourKeychainService,
|
||||||
// where the file briefly exists at default umask, and no chance of
|
kSecAttrAccount as String: ourKeychainAccount,
|
||||||
// following a malicious symlink at the destination path.
|
]
|
||||||
try SafeFile.write(data, to: url.path, mode: 0o600)
|
let attributes: [String: Any] = [
|
||||||
} catch {
|
kSecValueData as String: data,
|
||||||
throw StoreError.fileWriteFailed(String(describing: error))
|
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() {
|
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())
|
try? FileManager.default.removeItem(at: cacheFileURL())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,28 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
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 checkIntervalSeconds: TimeInterval = 2 * 24 * 60 * 60
|
||||||
private let lastCheckKey = "UpdateChecker.lastCheckDate"
|
private let lastCheckKey = "UpdateChecker.lastCheckDate"
|
||||||
private let cachedVersionKey = "UpdateChecker.latestVersion"
|
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
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
|
|
@ -37,19 +55,24 @@ final class UpdateChecker {
|
||||||
}
|
}
|
||||||
|
|
||||||
func check() async {
|
func check() async {
|
||||||
|
updateError = nil
|
||||||
guard let url = URL(string: releasesAPI) else { return }
|
guard let url = URL(string: releasesAPI) else { return }
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent")
|
request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent")
|
||||||
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (data, _) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||||
guard let asset = release.assets.first(where: {
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
$0.name.hasPrefix("CodeBurnMenubar-v") && $0.name.hasSuffix(".zip")
|
throw UpdateCheckError.http(status)
|
||||||
}) else { return }
|
}
|
||||||
|
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: "CodeBurnMenubar-", with: "")
|
||||||
.replacingOccurrences(of: ".zip", with: "")
|
.replacingOccurrences(of: ".zip", with: "")
|
||||||
|
|
||||||
|
|
@ -57,22 +80,50 @@ final class UpdateChecker {
|
||||||
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastCheckKey)
|
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastCheckKey)
|
||||||
UserDefaults.standard.set(version, forKey: cachedVersionKey)
|
UserDefaults.standard.set(version, forKey: cachedVersionKey)
|
||||||
} catch {
|
} catch {
|
||||||
|
updateError = "Update check failed: \(error.localizedDescription)"
|
||||||
NSLog("CodeBurn: update check failed: \(error)")
|
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() {
|
func performUpdate() {
|
||||||
isUpdating = true
|
isUpdating = true
|
||||||
updateError = nil
|
updateError = nil
|
||||||
|
|
||||||
let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"])
|
let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"])
|
||||||
let errPipe = Pipe()
|
let errPipe = Pipe()
|
||||||
|
let errBuffer = LockedDataBuffer()
|
||||||
process.standardOutput = FileHandle.nullDevice
|
process.standardOutput = FileHandle.nullDevice
|
||||||
process.standardError = errPipe
|
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
|
process.terminationHandler = { [weak self] proc in
|
||||||
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
|
timeoutTask.cancel()
|
||||||
let stderr = String(data: errData, encoding: .utf8) ?? ""
|
errPipe.fileHandleForReading.readabilityHandler = nil
|
||||||
|
let stderrData = errBuffer.snapshot()
|
||||||
|
let stderr = Self.sanitizeForDisplay(String(data: stderrData, encoding: .utf8) ?? "")
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.isUpdating = false
|
self.isUpdating = false
|
||||||
|
|
@ -93,14 +144,41 @@ final class UpdateChecker {
|
||||||
NSLog("CodeBurn: update spawn failed: \(error)")
|
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 tag_name: String
|
||||||
let assets: [GitHubAsset]
|
let assets: [GitHubAsset]
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct GitHubAsset: Decodable {
|
struct GitHubAsset: Decodable {
|
||||||
let name: String
|
let name: String
|
||||||
let browser_download_url: 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
|
/// PATH additions for GUI-launched apps, which otherwise get a minimal PATH that misses
|
||||||
/// Homebrew and npm global installs.
|
/// Homebrew and npm global installs.
|
||||||
private static let additionalPathEntries = ["/opt/homebrew/bin", "/usr/local/bin"]
|
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
|
/// 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
|
/// if every whitespace-delimited token passes `safeArgPattern`. Otherwise falls back to the
|
||||||
/// plain `codeburn` name (resolved via PATH).
|
/// plain `codeburn` name (resolved via PATH).
|
||||||
static func baseArgv() -> [String] {
|
static func baseArgv() -> [String] {
|
||||||
guard let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], !raw.isEmpty else {
|
if ProcessInfo.processInfo.environment["CODEBURN_ALLOW_DEV_BIN"] == "1",
|
||||||
return ["codeburn"]
|
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 {
|
return installedArgv()
|
||||||
NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using default 'codeburn'")
|
}
|
||||||
return ["codeburn"]
|
|
||||||
|
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`
|
/// 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 .claude: return Theme.categoricalClaude
|
||||||
case .codex: return Theme.categoricalCodex
|
case .codex: return Theme.categoricalCodex
|
||||||
case .cursor: return Theme.categoricalCursor
|
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 .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 .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)
|
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 .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 .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 .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)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if updateChecker.updateAvailable {
|
if updateChecker.updateAvailable || updateChecker.updateError != nil {
|
||||||
UpdateBadge()
|
UpdateBadge()
|
||||||
}
|
}
|
||||||
AccentPicker()
|
AccentPicker()
|
||||||
|
|
@ -409,18 +409,25 @@ private struct UpdateBadge: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
updateChecker.performUpdate()
|
if updateChecker.updateAvailable {
|
||||||
|
updateChecker.performUpdate()
|
||||||
|
} else {
|
||||||
|
Task { await updateChecker.check() }
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
if updateChecker.isUpdating {
|
if updateChecker.isUpdating {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.mini)
|
.controlSize(.mini)
|
||||||
.scaleEffect(0.7)
|
.scaleEffect(0.7)
|
||||||
|
} else if updateChecker.updateError != nil {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 10))
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "arrow.down.circle.fill")
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
}
|
}
|
||||||
Text(updateChecker.isUpdating ? "Updating..." : "Update")
|
Text(updateChecker.isUpdating ? "Updating..." : (updateChecker.updateError == nil ? "Update" : "Failed"))
|
||||||
.font(.system(size: 10, weight: .medium))
|
.font(.system(size: 10, weight: .medium))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
|
|
@ -430,6 +437,7 @@ private struct UpdateBadge: View {
|
||||||
.tint(Theme.brandAccent)
|
.tint(Theme.brandAccent)
|
||||||
.controlSize(.mini)
|
.controlSize(.mini)
|
||||||
.disabled(updateChecker.isUpdating)
|
.disabled(updateChecker.isUpdating)
|
||||||
|
.help(updateChecker.updateError ?? "Install the latest menubar build")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -537,12 +545,7 @@ struct FooterBar: View {
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
// showLoading: true is safe now that the overlay condition uses
|
refreshNow()
|
||||||
// `!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) }
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
|
Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
|
@ -588,6 +591,14 @@ struct FooterBar: View {
|
||||||
TerminalLauncher.open(subcommand: ["report"])
|
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 {
|
private enum ExportFormat {
|
||||||
case csv, json
|
case csv, json
|
||||||
var cliName: String { self == .csv ? "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 { spawn } from 'node:child_process'
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { createWriteStream } from 'node:fs'
|
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 { homedir, platform, tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { pipeline } from 'node:stream/promises'
|
import { pipeline } from 'node:stream/promises'
|
||||||
import { Readable } from 'node:stream'
|
import { Readable } from 'node:stream'
|
||||||
|
|
||||||
/// Public GitHub repo that hosts signed macOS release builds. `/releases/latest` returns the
|
/// Public GitHub repo that hosts macOS release builds. CLI and menubar releases share
|
||||||
/// newest tagged release; we filter its assets list for our zipped .app bundle.
|
/// the repository, so we scan recent releases and choose the newest `mac-v*` release
|
||||||
const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases/latest'
|
/// 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 APP_BUNDLE_NAME = 'CodeBurnMenubar.app'
|
||||||
|
const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar'
|
||||||
const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/
|
const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/
|
||||||
const APP_PROCESS_NAME = 'CodeBurnMenubar'
|
const APP_PROCESS_NAME = 'CodeBurnMenubar'
|
||||||
const SUPPORTED_OS = 'darwin'
|
const SUPPORTED_OS = 'darwin'
|
||||||
const MIN_MACOS_MAJOR = 14
|
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 InstallResult = { installedPath: string; launched: boolean }
|
||||||
|
|
||||||
export type ReleaseAsset = { name: string; browser_download_url: string }
|
export type ReleaseAsset = { name: string; browser_download_url: string }
|
||||||
export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] }
|
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 {
|
export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedAssets {
|
||||||
const zip = release.assets.find(a => VERSIONED_ASSET_PATTERN.test(a.name))
|
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.`
|
`Check https://github.com/getagentseal/codeburn/releases.`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`) ?? null
|
const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`)
|
||||||
return { zip, checksum }
|
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 {
|
function userApplicationsDir(): string {
|
||||||
|
|
@ -81,8 +99,8 @@ async function fetchLatestReleaseAssets(): Promise<ResolvedAssets> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`GitHub release lookup failed: HTTP ${response.status}`)
|
throw new Error(`GitHub release lookup failed: HTTP ${response.status}`)
|
||||||
}
|
}
|
||||||
const body = await response.json() as ReleaseResponse
|
const body = await response.json() as ReleaseResponse[]
|
||||||
return resolveMenubarReleaseAssets(body)
|
return resolveLatestMenubarReleaseAssets(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyChecksum(archivePath: string, checksumUrl: string): Promise<void> {
|
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> {
|
async function isAppRunning(): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME])
|
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> {
|
export async function installMenubarApp(options: { force?: boolean } = {}): Promise<InstallResult> {
|
||||||
await ensureSupportedPlatform()
|
await ensureSupportedPlatform()
|
||||||
|
await persistCodeburnPath()
|
||||||
|
|
||||||
const appsDir = userApplicationsDir()
|
const appsDir = userApplicationsDir()
|
||||||
const targetPath = join(appsDir, APP_BUNDLE_NAME)
|
const targetPath = join(appsDir, APP_BUNDLE_NAME)
|
||||||
|
|
@ -174,12 +244,8 @@ export async function installMenubarApp(options: { force?: boolean } = {}): Prom
|
||||||
console.log(`Downloading ${zip.name}...`)
|
console.log(`Downloading ${zip.name}...`)
|
||||||
await downloadToFile(zip.browser_download_url, archivePath)
|
await downloadToFile(zip.browser_download_url, archivePath)
|
||||||
|
|
||||||
if (checksum) {
|
console.log('Verifying checksum...')
|
||||||
console.log('Verifying checksum...')
|
await verifyChecksum(archivePath, checksum.browser_download_url)
|
||||||
await verifyChecksum(archivePath, checksum.browser_download_url)
|
|
||||||
} else {
|
|
||||||
console.log('Warning: no checksum file found in release, skipping verification.')
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Unpacking...')
|
console.log('Unpacking...')
|
||||||
await runCommand('/usr/bin/ditto', ['-x', '-k', archivePath, stagingDir])
|
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}.`)
|
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
|
// 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
|
// "cannot verify developer" prompt even for a signed + notarized app when the bundle
|
||||||
// was delivered via curl/fetch instead of the Mac App Store.
|
// was delivered via curl/fetch instead of the Mac App Store.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
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) {
|
function asset(name: string) {
|
||||||
return { name, browser_download_url: `https://example.test/${name}` }
|
return { name, browser_download_url: `https://example.test/${name}` }
|
||||||
|
|
@ -34,4 +38,38 @@ describe('resolveMenubarReleaseAssets', () => {
|
||||||
|
|
||||||
expect(() => resolveMenubarReleaseAssets(release)).toThrow(/versioned zip/)
|
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