chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:26:02 +08:00
commit a0a48b5e45
437 changed files with 100767 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.build/
.swiftpm/
Package.resolved
*.xcodeproj/
*.xcworkspace/
DerivedData/
+30
View File
@@ -0,0 +1,30 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "CodeBurnMenubar",
platforms: [
// macOS 14 (Sonoma) is the floor: matches Info.plist LSMinimumSystemVersion,
// the CLI install guard (MIN_MACOS_MAJOR=14), and mac/README. The earlier .v15
// bump for NSAttributedString(attachment:) was a misdiagnosis, that initializer
// is AppKit since macOS 10.0, so the binary's minos must not exclude Sonoma users.
.macOS(.v14)
],
products: [
.executable(name: "CodeBurnMenubar", targets: ["CodeBurnMenubar"])
],
targets: [
.executableTarget(
name: "CodeBurnMenubar",
path: "Sources/CodeBurnMenubar",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency")
]
),
.testTarget(
name: "CodeBurnMenubarTests",
dependencies: ["CodeBurnMenubar"],
path: "Tests/CodeBurnMenubarTests"
)
]
)
+112
View File
@@ -0,0 +1,112 @@
# CodeBurn Menubar (macOS)
Native Swift + SwiftUI menubar app. The codeburn menubar surface.
## Requirements
- macOS 14+ (Sonoma)
- Swift 6.0+ toolchain (bundled with Xcode 16 or standalone)
- `codeburn` CLI installed globally (`npm install -g codeburn`)
## Install (end users)
One command:
```bash
codeburn menubar
```
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
For contributors running a local build instead of the packaged release:
```bash
npm install -g codeburn # CLI the app shells out to for data
git clone https://github.com/getagentseal/codeburn.git
cd codeburn/mac
swift build -c release
.build/release/CodeBurnMenubar # launch
```
#### On macOS 14 (Sonoma) without Xcode 16
`swift build` above assumes the macOS 15 SDK, whose SwiftUI marks the `View`
protocol `@MainActor`. The Sonoma SDK (shipped with Command Line Tools) lacks
that annotation, so a plain build fails with ~80 `main actor-isolated ... from a
nonisolated context` errors.
(The `-10825` launch failure itself is fixed by `Package.swift`'s `.macOS(.v14)`
deployment target: ld64 drops the macOS-15-only `libswift_errno.dylib`
dependency for any build with that target, regardless of which SDK built it, including the CI-distributed release. This local-build path exists only for
the narrower case of building on a Sonoma machine with nothing but the
Command Line Tools, where the SDK's un-annotated `View` protocol needs the
`@MainActor` patch below.)
Use the helper, which builds against the local macOS 14 SDK with a standalone
[swift.org](https://www.swift.org/install/macos/) Swift 6.x toolchain and
adds explicit `@MainActor` to the views in a scratch copy (repo sources stay
clean), producing a `minos = 14.0` bundle installed to `~/Applications`:
```bash
mac/Scripts/build-local.sh # then: codeburn menubar
```
## Build & run (dev against a local CLI checkout)
```bash
cd mac
swift build
# Point the app at your dev CLI build instead of the globally installed `codeburn`:
npm --prefix .. run build
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.
## Data source
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.
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
```
mac/
├── Package.swift SwiftPM manifest (deployment target: macOS 14)
├── Scripts/
│ ├── package-app.sh CI: universal signed .app + zip + checksum
│ └── build-local.sh Local macOS 14 build (Sonoma SDK + @MainActor patch)
├── Sources/CodeBurnMenubar/
│ ├── CodeBurnApp.swift @main + MenuBarExtra scene
│ ├── AppStore.swift @Observable store + enums
│ ├── Data/MenubarPayload.swift Codable payload types + placeholder
│ ├── Theme/Theme.swift Design tokens (warm terracotta palette)
│ └── Views/MenuBarContent.swift Popover layout + footer action bar
└── README.md This file
```
## Status
Live data wired. Next iterations:
1. FSEvents watch for `~/.claude/projects/` changes (debounced refresh on real edits)
2. Persistent disk cache for optimize findings so the default refresh can include them without the 30-second penalty
3. Currency metadata in the JSON payload + Swift-side formatting
4. Sparkle auto-update
5. DMG packaging + Homebrew Cask tap
## Design tokens
Sourced from `~/codeburn-menubar-mac-swiftui.html`. Warm terracotta-ember palette:
- Accent (light): `#C9521D`
- Accent (dark): `#E8774A`
- Ember deep: `#8B3E13`
- Ember glow: `#F0A070`
- Surface (light): `#FAF7F3`
- Surface (dark): `#1C1816`
SF Mono for currency values; SF Pro Rounded for hero.
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# ============================================================================
# build-local.sh, Build CodeBurnMenubar.app on a macOS 14 (Sonoma) machine.
# ============================================================================
# Why this exists
# ---------------
# Package.swift's `.macOS(.v14)` deployment target already fixes the -10825
# launch failure for every build, including the CI-distributed release: ld64
# drops the macOS-15-only libswift_errno.dylib dependency based on the
# deployment target, not the SDK used to build. This script is not about that.
#
# It exists for the narrower case of building on a Sonoma machine that only
# has the Command Line Tools (macOS 14 SDK). That SDK's SwiftUI does NOT carry
# the @MainActor annotations the macOS 15 SDK added to the `View` protocol, so
# a plain `swift build` there fails with ~80 `main actor-isolated ... from a
# nonisolated context` errors. This script copies the sources to a scratch
# dir, gives every `View`/`App` conformance an explicit `@MainActor` there
# (repo sources stay untouched), and builds a universal bundle with a
# swift.org Swift 6.2 toolchain against the local macOS 14 SDK.
#
# Prerequisites
# - Command Line Tools (provides the macOS 14 SDK + sips/iconutil/codesign)
# - A swift.org Swift 6.x toolchain in ~/Library/Developer/Toolchains/
# download: https://www.swift.org/install/macos/ (Swift 6.2 recommended)
#
# Usage: mac/Scripts/build-local.sh [<version>] (defaults to "dev")
# ----------------------------------------------------------------------------
set -euo pipefail
VERSION="${1:-dev}"
BUNDLE_ID="org.agentseal.codeburn-menubar"
EXE="CodeBurnMenubar"
MIN_MACOS="14.0"
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
MAC_DIR="${ROOT}/mac"
ICON_SOURCE="${ROOT}/assets/menubar-logo.png"
SCRATCH="$(mktemp -d /tmp/codeburn-local-build.XXXXXX)"
APPS="${HOME}/Applications"
BUNDLE="${APPS}/${EXE}.app"
trap 'rm -rf "${SCRATCH}"' EXIT
# --- locate a Swift 6.x toolchain -------------------------------------------
TC=""
for cand in "${HOME}/Library/Developer/Toolchains/swift-6.2-RELEASE.xctoolchain" \
"${HOME}/Library/Developer/Toolchains/swift-latest.xctoolchain" \
/Library/Developer/Toolchains/swift-latest.xctoolchain; do
[[ -x "${cand}/usr/bin/swift" ]] && { TC="${cand}"; break; }
done
if [[ -z "${TC}" ]]; then
echo "✗ No swift.org Swift 6.x toolchain found in ~/Library/Developer/Toolchains/." >&2
echo " Install one from https://www.swift.org/install/macos/ (Swift 6.2)." >&2
exit 1
fi
SWIFT="${TC}/usr/bin/swift"
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)"
case "${SDK_VERSION}" in
14.*) ;;
*)
echo "✗ Active SDK is macOS ${SDK_VERSION}, not 14.x, xcode-select is likely" >&2
echo " pointed at a newer Xcode instead of the Command Line Tools. Run:" >&2
echo " sudo xcode-select -s /Library/Developer/CommandLineTools" >&2
exit 1
;;
esac
echo "▸ Toolchain : $("${SWIFT}" --version | head -1)"
echo "▸ SDK : ${SDKROOT} (${SDK_VERSION})"
# --- copy sources and add explicit @MainActor to SwiftUI views --------------
echo "▸ Staging sources in ${SCRATCH}..."
# Tests/ is copied only so the manifest's testTarget path resolves; `swift build`
# (product only) never compiles it, so it needs no @MainActor patching.
cp -R "${MAC_DIR}/Sources" "${MAC_DIR}/Tests" "${MAC_DIR}/Package.swift" "${SCRATCH}/"
find "${SCRATCH}/Sources" -name "*.swift" -print0 | while IFS= read -r -d '' f; do
# Slurp mode ([^{]* spans newlines) so this also catches multi-line generic
# struct headers and `extension X: View` conformances, not just the
# single-line `struct X: View {` shape the current sources happen to use.
perl -0777 -i -pe '
s/^((?:private |public |fileprivate |internal )?(?:struct|extension)\s+\w+(?:<[^{]*?>)?\s*:[^{]*\bView\b[^{]*\{)/\@MainActor\n$1/gm;
s/^(struct\s+\w+\s*:[^{]*\bApp\b[^{]*\{)/\@MainActor\n$1/gm;
s/\@MainActor\n\@MainActor\n/\@MainActor\n/g;
' "$f"
done
# --- build each arch separately, then lipo into one universal binary --------
# `swift build --arch arm64 --arch x86_64` together shells out to xcbuild,
# which the Command Line Tools doesn't ship, each arch alone stays on the
# plain SwiftPM build path, so build twice and merge with lipo instead.
BINS=()
for arch in arm64 x86_64; do
echo "▸ Building ${arch} release..."
( cd "${SCRATCH}" && "${SWIFT}" build -c release --arch "${arch}" )
bin="$(cd "${SCRATCH}" && "${SWIFT}" build -c release --arch "${arch}" --show-bin-path)/${EXE}"
[[ -x "${bin}" ]] || { echo "${arch} build produced no binary" >&2; exit 1; }
BINS+=("${bin}")
done
BIN="${SCRATCH}/${EXE}-universal"
lipo -create -output "${BIN}" "${BINS[@]}"
# --- assemble the .app bundle ------------------------------------------------
echo "▸ Assembling ${BUNDLE}..."
pkill -x "${EXE}" 2>/dev/null || true; sleep 1
rm -rf "${BUNDLE}"
mkdir -p "${BUNDLE}/Contents/MacOS" "${BUNDLE}/Contents/Resources"
cp "${BIN}" "${BUNDLE}/Contents/MacOS/${EXE}"
cp "${ICON_SOURCE}" "${BUNDLE}/Contents/Resources/menubar-logo.png"
ICONSET="${SCRATCH}/AppIcon.iconset"; mkdir -p "${ICONSET}"
for spec in "16:16x16" "32:16x16@2x" "32:32x32" "64:32x32@2x" "128:128x128" \
"256:128x128@2x" "256:256x256" "512:256x256@2x" "512:512x512"; do
sips -z "${spec%%:*}" "${spec%%:*}" "${ICON_SOURCE}" --out "${ICONSET}/icon_${spec##*:}.png" >/dev/null
done
cp "${ICON_SOURCE}" "${ICONSET}/icon_512x512@2x.png"
iconutil -c icns "${ICONSET}" -o "${BUNDLE}/Contents/Resources/AppIcon.icns"
cat > "${BUNDLE}/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key><string>en</string>
<key>CFBundleDisplayName</key><string>CodeBurn Menubar</string>
<key>CFBundleExecutable</key><string>${EXE}</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>${EXE}</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>${VERSION}</string>
<key>CFBundleVersion</key><string>${VERSION}</string>
<key>LSMinimumSystemVersion</key><string>${MIN_MACOS}</string>
<key>LSUIElement</key><true/>
<key>NSHighResolutionCapable</key><true/>
<key>NSHumanReadableCopyright</key><string>© AgentSeal</string>
</dict>
</plist>
PLIST
printf 'APPL????' > "${BUNDLE}/Contents/PkgInfo"
echo "▸ Ad-hoc signing..."
codesign --force --sign - --timestamp=none --deep "${BUNDLE}"
codesign --verify --deep --strict "${BUNDLE}"
echo ""
echo "✓ Installed ${BUNDLE}"
lipo -info "${BUNDLE}/Contents/MacOS/${EXE}" | sed 's/^/ /'
vtool -show-build "${BUNDLE}/Contents/MacOS/${EXE}" 2>/dev/null | grep -iE "minos|sdk" | sed 's/^/ /'
echo " Launch with: codeburn menubar (or: open '${BUNDLE}')"
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# Builds a universal CodeBurnMenubar.app bundle from the SwiftPM target and drops a
# distributable zip alongside. Used by the GitHub release workflow; also runnable locally.
#
# Usage:
# mac/Scripts/package-app.sh [<version>]
# Defaults to `dev` if no version is given.
set -euo pipefail
VERSION="${1:-dev}"
ASSET_VERSION="${VERSION#mac-}"
BUNDLE_VERSION="${ASSET_VERSION#v}"
BUNDLE_NAME="CodeBurnMenubar.app"
BUNDLE_ID="org.agentseal.codeburn-menubar"
EXECUTABLE_NAME="CodeBurnMenubar"
MIN_MACOS="14.0"
repo_root() {
git rev-parse --show-toplevel 2>/dev/null || (cd "$(dirname "$0")/../.." && pwd)
}
ROOT=$(repo_root)
MAC_DIR="${ROOT}/mac"
DIST_DIR="${MAC_DIR}/.build/dist"
ICON_SOURCE="${ROOT}/assets/menubar-logo.png"
cd "${MAC_DIR}"
echo "▸ Cleaning previous dist..."
rm -rf "${DIST_DIR}"
mkdir -p "${DIST_DIR}"
echo "▸ Building universal binary (arm64 + x86_64)..."
swift build -c release --arch arm64 --arch x86_64
BIN_PATH=$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path)
BUILT_BINARY="${BIN_PATH}/${EXECUTABLE_NAME}"
if [[ ! -x "${BUILT_BINARY}" ]]; then
echo "Binary not found at ${BUILT_BINARY}" >&2
exit 1
fi
echo "▸ Assembling ${BUNDLE_NAME}..."
BUNDLE="${DIST_DIR}/${BUNDLE_NAME}"
mkdir -p "${BUNDLE}/Contents/MacOS"
mkdir -p "${BUNDLE}/Contents/Resources"
cp "${BUILT_BINARY}" "${BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}"
cp "${ICON_SOURCE}" "${BUNDLE}/Contents/Resources/menubar-logo.png"
ICONSET="${DIST_DIR}/AppIcon.iconset"
rm -rf "${ICONSET}"
mkdir -p "${ICONSET}"
sips -z 16 16 "${ICON_SOURCE}" --out "${ICONSET}/icon_16x16.png" >/dev/null
sips -z 32 32 "${ICON_SOURCE}" --out "${ICONSET}/icon_16x16@2x.png" >/dev/null
sips -z 32 32 "${ICON_SOURCE}" --out "${ICONSET}/icon_32x32.png" >/dev/null
sips -z 64 64 "${ICON_SOURCE}" --out "${ICONSET}/icon_32x32@2x.png" >/dev/null
sips -z 128 128 "${ICON_SOURCE}" --out "${ICONSET}/icon_128x128.png" >/dev/null
sips -z 256 256 "${ICON_SOURCE}" --out "${ICONSET}/icon_128x128@2x.png" >/dev/null
sips -z 256 256 "${ICON_SOURCE}" --out "${ICONSET}/icon_256x256.png" >/dev/null
sips -z 512 512 "${ICON_SOURCE}" --out "${ICONSET}/icon_256x256@2x.png" >/dev/null
sips -z 512 512 "${ICON_SOURCE}" --out "${ICONSET}/icon_512x512.png" >/dev/null
cp "${ICON_SOURCE}" "${ICONSET}/icon_512x512@2x.png"
iconutil -c icns "${ICONSET}" -o "${BUNDLE}/Contents/Resources/AppIcon.icns"
rm -rf "${ICONSET}"
cat > "${BUNDLE}/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>CodeBurn Menubar</string>
<key>CFBundleExecutable</key>
<string>${EXECUTABLE_NAME}</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>${BUNDLE_ID}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>${EXECUTABLE_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${BUNDLE_VERSION}</string>
<key>CFBundleVersion</key>
<string>${BUNDLE_VERSION}</string>
<key>LSMinimumSystemVersion</key>
<string>${MIN_MACOS}</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© AgentSeal</string>
</dict>
</plist>
PLIST
cat > "${BUNDLE}/Contents/PkgInfo" <<'PKG'
APPL????
PKG
# Sign so macOS treats the bundle as internally consistent. Set CODESIGN_IDENTITY
# to a stable identity (Developer ID Application for distribution, or an Apple
# Development cert for local testing) so the TCC "access data from other apps"
# grant persists across rebuilds. Falls back to ad-hoc when unset (e.g. CI), which
# re-prompts on every build because each ad-hoc build has a fresh code identity.
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-}"
if [[ -n "${CODESIGN_IDENTITY}" ]]; then
echo "▸ Signing with identity: ${CODESIGN_IDENTITY}"
codesign --force --sign "${CODESIGN_IDENTITY}" --options runtime --timestamp=none --deep "${BUNDLE}"
else
echo "▸ Ad-hoc signing (set CODESIGN_IDENTITY for a persistent TCC grant)..."
codesign --force --sign - --timestamp=none --deep "${BUNDLE}"
fi
codesign --verify --deep --strict "${BUNDLE}"
echo "▸ Verifying deployment target and libswift_errno absence..."
BUILT_EXE="${BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}"
MINOS_LINES=$(vtool -show-build "${BUILT_EXE}" 2>/dev/null | awk '/minos/{print $2}')
if [[ -z "${MINOS_LINES}" ]]; then
echo "Could not read a minos deployment target from ${BUILT_EXE} (vtool returned nothing)." >&2
exit 1
fi
BAD_MINOS=$(printf '%s\n' "${MINOS_LINES}" | grep -v '^14\.0$' || true)
if [[ -n "${BAD_MINOS}" ]]; then
echo "✗ Expected minos 14.0 for every arch slice, found: ${BAD_MINOS}" >&2
echo " Did Package.swift's platforms: [.macOS(...)] regress past .v14?" >&2
exit 1
fi
if otool -L "${BUILT_EXE}" | grep libswift_errno | grep -qv 'weak'; then
echo "${BUILT_EXE} links libswift_errno.dylib (macOS 15+ only), would fail on Sonoma with -10825." >&2
exit 1
fi
echo " minos 14.0 confirmed, no libswift_errno dependency."
ZIP_NAME="CodeBurnMenubar-${ASSET_VERSION}.zip"
ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
echo "▸ Packaging ${ZIP_NAME}..."
(cd "${DIST_DIR}" && COPYFILE_DISABLE=1 /usr/bin/ditto -c -k --norsrc --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}")
CHECKSUM_NAME="${ZIP_NAME}.sha256"
CHECKSUM_PATH="${DIST_DIR}/${CHECKSUM_NAME}"
echo "▸ Computing SHA-256 checksum..."
(cd "${DIST_DIR}" && shasum -a 256 "${ZIP_NAME}" > "${CHECKSUM_NAME}")
echo ""
echo "✓ Built ${ZIP_PATH}"
echo "✓ Checksum ${CHECKSUM_PATH}"
cat "${CHECKSUM_PATH}"
ls -la "${DIST_DIR}"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
import Foundation
enum AppVersion {
static var bundleShortVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
}
static var bundleBuildVersion: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
}
static var normalizedBundleShortVersion: String {
normalize(bundleShortVersion)
}
static var normalizedBundleBuildVersion: String {
normalize(bundleBuildVersion)
}
static var displayBundleShortVersion: String {
display(bundleShortVersion)
}
static func normalize(_ version: String) -> String {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.lowercased().hasPrefix("mac-v") {
return String(trimmed.dropFirst(5))
}
if trimmed.lowercased().hasPrefix("v") {
return String(trimmed.dropFirst())
}
return trimmed
}
static func display(_ version: String) -> String {
let normalized = normalize(version)
guard !normalized.isEmpty else { return "v?" }
if normalized == "?" || normalized == "dev" || normalized == "dev-preview" || normalized == "" {
return normalized
}
return "v\(normalized)"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,281 @@
import Foundation
import Observation
private let fxCacheTTLSeconds: TimeInterval = 24 * 3600
private let frankfurterBaseURL = "https://api.frankfurter.app/latest?from=USD&to="
/// Defensive bounds on any fetched FX rate. Real-world USDX rates sit in [0.0001, 200000]
/// for every ISO 4217 pair; anything outside is either a parser bug or a MITM poisoning
/// attempt. We clamp hard so UI can't render NaN, negative, or astronomical numbers.
private let minValidFXRate: Double = 0.0001
private let maxValidFXRate: Double = 1_000_000
private let fxFetchTimeoutSeconds: TimeInterval = 10
@MainActor @Observable
final class CurrencyState: Sendable {
static let shared = CurrencyState()
var code: String = "USD"
var rate: Double = 1.0
var symbol: String = "$"
private init() {}
/// Applies a new currency context. Callers must invoke on the main actor so @Observable
/// view updates run on the UI thread. Rejects non-finite or out-of-band rates so a
/// poisoned Frankfurter response can't corrupt displayed costs.
func apply(code: String, rate: Double?, symbol: String) {
self.code = code
self.symbol = symbol
if let r = rate, r.isFinite, r >= minValidFXRate, r <= maxValidFXRate {
self.rate = r
}
}
nonisolated static func symbolForCode(_ code: String) -> String {
// Some locales return "US$" for USD or "CA$" for CAD via NumberFormatter. Prefer the
// plain glyph form everyone recognises.
if let override = symbolOverrides[code] { return override }
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = code
formatter.locale = Locale(identifier: "en_\(code.prefix(2))")
return formatter.currencySymbol ?? code
}
nonisolated private static let symbolOverrides: [String: String] = [
"USD": "$",
"CAD": "$",
"AUD": "$",
"NZD": "$",
"HKD": "$",
"SGD": "$",
"MXN": "$",
"EUR": "\u{20AC}",
"GBP": "\u{00A3}",
"JPY": "\u{00A5}",
"CNY": "\u{00A5}",
"KRW": "\u{20A9}",
"INR": "\u{20B9}",
"BRL": "R$",
"CHF": "CHF",
"SEK": "kr",
"DKK": "kr",
"ZAR": "R",
"RON": "lei"
]
}
actor FXRateCache {
static let shared = FXRateCache()
private struct Entry: Codable {
let rate: Double
let savedAt: TimeInterval
}
private var entries: [String: Entry] = [:]
private var loaded = false
private var cacheFilePath: String {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
return base
.appendingPathComponent("codeburn-mac", isDirectory: true)
.appendingPathComponent("fx-rates.json")
.path
}
private func loadIfNeeded() {
guard !loaded else { return }
loaded = true
do {
let data = try SafeFile.read(from: cacheFilePath)
let decoded = try JSONDecoder().decode([String: Entry].self, from: data)
// Drop any persisted entries whose rate violates the sanity bounds -- covers an
// old cache that was written before the clamp was introduced.
entries = decoded.filter { _, entry in
entry.rate.isFinite && entry.rate >= minValidFXRate && entry.rate <= maxValidFXRate
}
} catch {
entries = [:]
}
}
private func persist() {
guard let data = try? JSONEncoder().encode(entries) else { return }
try? SafeFile.write(data, to: cacheFilePath)
}
/// Returns a cached rate regardless of freshness. Nil if never fetched.
func cachedRate(for code: String) -> Double? {
if code == "USD" { return 1.0 }
loadIfNeeded()
return entries[code]?.rate
}
/// Returns a fresh rate, fetching from Frankfurter when cache is stale or absent. Nil on
/// failure. The returned rate is always finite, positive, and within the sanity bounds.
func rate(for code: String) async -> Double? {
if code == "USD" { return 1.0 }
loadIfNeeded()
if let entry = entries[code],
Date().timeIntervalSince1970 - entry.savedAt < fxCacheTTLSeconds {
return entry.rate
}
guard let url = URL(string: "\(frankfurterBaseURL)\(code)") else { return entries[code]?.rate }
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = fxFetchTimeoutSeconds
config.tlsMinimumSupportedProtocolVersion = .TLSv12
let session = URLSession(configuration: config)
do {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
return entries[code]?.rate
}
struct Response: Decodable { let rates: [String: Double] }
let decoded = try JSONDecoder().decode(Response.self, from: data)
guard let fresh = decoded.rates[code],
fresh.isFinite, fresh >= minValidFXRate, fresh <= maxValidFXRate else {
NSLog("CodeBurn: discarding out-of-band FX rate for \(code)")
return entries[code]?.rate
}
entries[code] = Entry(rate: fresh, savedAt: Date().timeIntervalSince1970)
persist()
return fresh
} catch {
return entries[code]?.rate
}
}
}
/// Reads and writes the CLI's persisted currency config (~/.config/codeburn/config.json).
/// Uses an on-disk flock so a concurrent `codeburn currency ...` invocation from a terminal
/// can't race the menubar and silently drop each other's writes (TOCTOU on config.json).
enum CLICurrencyConfig {
private static var configDir: String {
(NSHomeDirectory() as NSString).appendingPathComponent(".config/codeburn")
}
private static var configPath: String {
(configDir as NSString).appendingPathComponent("config.json")
}
private static var lockPath: String {
(configDir as NSString).appendingPathComponent(".config.lock")
}
static func loadCode() -> String? {
guard
let data = try? SafeFile.read(from: configPath),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let currency = json["currency"] as? [String: Any],
let code = currency["code"] as? String
else {
return nil
}
return code.uppercased()
}
static func persist(code: String) {
do {
try SafeFile.withExclusiveLock(at: lockPath) {
var existing: [String: Any] = [:]
if let data = try? SafeFile.read(from: configPath),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
existing = parsed
}
if code == "USD" {
existing.removeValue(forKey: "currency")
} else {
existing["currency"] = [
"code": code,
"symbol": CurrencyState.symbolForCode(code)
]
}
guard let data = try? JSONSerialization.data(
withJSONObject: existing,
options: [.prettyPrinted, .sortedKeys]
) else {
return
}
try SafeFile.write(data, to: configPath, mode: 0o600)
}
} catch {
NSLog("CodeBurn: failed to persist currency config: \(error)")
}
}
}
struct CodeburnCLIConfigStore {
let homeDirectory: String
init(homeDirectory: String = NSHomeDirectory()) {
self.homeDirectory = homeDirectory
}
private var configDir: String {
(homeDirectory as NSString).appendingPathComponent(".config/codeburn")
}
private var configPath: String {
(configDir as NSString).appendingPathComponent("config.json")
}
private var lockPath: String {
(configDir as NSString).appendingPathComponent(".config.lock")
}
func loadDevinAcuUsdRate() -> Double? {
guard
let data = try? SafeFile.read(from: configPath),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let devin = json["devin"] as? [String: Any],
let rate = devin["acuUsdRate"] as? Double,
rate.isFinite,
rate > 0
else {
return nil
}
return rate
}
func persistDevinAcuUsdRate(_ rate: Double) {
guard rate.isFinite, rate > 0 else { return }
do {
try SafeFile.withExclusiveLock(at: lockPath) {
var existing: [String: Any] = [:]
if let data = try? SafeFile.read(from: configPath),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
existing = parsed
}
var devin = existing["devin"] as? [String: Any] ?? [:]
devin["acuUsdRate"] = rate
existing["devin"] = devin
guard let data = try? JSONSerialization.data(
withJSONObject: existing,
options: [.prettyPrinted, .sortedKeys]
) else {
return
}
try SafeFile.write(data, to: configPath, mode: 0o600)
}
} catch {
NSLog("CodeBurn: failed to persist Devin ACU config: \(error)")
}
}
}
enum CLIDevinConfig {
private static let store = CodeburnCLIConfigStore()
static func loadAcuUsdRate() -> Double? {
store.loadDevinAcuUsdRate()
}
static func persistAcuUsdRate(_ rate: Double) {
store.persistDevinAcuUsdRate(rate)
}
}
@@ -0,0 +1,74 @@
import Foundation
/// Reads and writes the CLI's `claudeConfigDirs` list in
/// `~/.config/codeburn/config.json`. The menubar is a GUI app that doesn't
/// inherit the user's shell environment, so it can't rely on `CLAUDE_CONFIG_DIRS`
/// to aggregate usage across multiple Claude config directories (work / personal
/// accounts). Persisting to the shared config file instead means every `codeburn`
/// invocation honors the list whether the menubar spawns it directly or the
/// user launches `codeburn report` in a terminal without injecting environment
/// variables through the shell/AppleScript paths (which only accept a strict
/// metacharacter-free allowlist that arbitrary directory paths can't satisfy).
///
/// Shares the same on-disk flock as `CLICurrencyConfig` so a concurrent
/// `codeburn` write from a terminal can't race the menubar and drop the other's
/// changes (TOCTOU on config.json).
enum CLIClaudeConfig {
private static var configDir: String {
(NSHomeDirectory() as NSString).appendingPathComponent(".config/codeburn")
}
private static var configPath: String {
(configDir as NSString).appendingPathComponent("config.json")
}
private static var lockPath: String {
(configDir as NSString).appendingPathComponent(".config.lock")
}
/// Returns the persisted config directories, or an empty array when none are
/// set (the CLI then falls back to `~/.claude`).
static func load() -> [String] {
guard
let data = try? SafeFile.read(from: configPath),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dirs = json["claudeConfigDirs"] as? [Any]
else {
return []
}
return dirs.compactMap { $0 as? String }.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
}
/// Persists the given directories. An empty list removes the key entirely so
/// the CLI reverts to its default `~/.claude` behavior. Entries are trimmed
/// and blanks dropped; order is preserved.
static func persist(dirs: [String]) {
let cleaned = dirs
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
do {
try SafeFile.withExclusiveLock(at: lockPath) {
var existing: [String: Any] = [:]
if let data = try? SafeFile.read(from: configPath),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
existing = parsed
}
if cleaned.isEmpty {
existing.removeValue(forKey: "claudeConfigDirs")
} else {
existing["claudeConfigDirs"] = cleaned
}
guard let data = try? JSONSerialization.data(
withJSONObject: existing,
options: [.prettyPrinted, .sortedKeys]
) else {
return
}
try SafeFile.write(data, to: configPath, mode: 0o600)
}
} catch {
NSLog("CodeBurn: failed to persist claudeConfigDirs config: \(error)")
}
}
}
@@ -0,0 +1,127 @@
import Foundation
public struct CapacitySnapshot: Sendable, Equatable {
public let percent: Double // 0..100, Anthropic-reported utilization
public let effectiveTokens: Double // weighted sum of input/output/cache tokens consumed at capture
public let capturedAt: Date
public init(percent: Double, effectiveTokens: Double, capturedAt: Date) {
self.percent = percent
self.effectiveTokens = effectiveTokens
self.capturedAt = capturedAt
}
}
public enum CapacityConfidence: String, Sendable {
case low, medium, solid
}
public struct CapacityEstimate: Sendable, Equatable {
public let capacity: Double // tokens equivalent to 100%
public let confidence: CapacityConfidence
public let sampleSize: Int // post-decorrelation count
public let nonLinearityWarning: Bool
public init(capacity: Double, confidence: CapacityConfidence, sampleSize: Int, nonLinearityWarning: Bool) {
self.capacity = capacity
self.confidence = confidence
self.sampleSize = sampleSize
self.nonLinearityWarning = nonLinearityWarning
}
}
public enum CapacityEstimator {
private static let minSampleSize = 5
private static let minPercentRange = 15.0
private static let recencyHalfLifeSeconds: Double = 30 * 86400
private static let solidR2 = 0.97
private static let mediumR2 = 0.85
private static let solidSampleThreshold = 15
private static let mediumSampleThreshold = 6
private static let nonLinearityRunLengthThreshold = 0.7
public static func estimate(_ snapshots: [CapacitySnapshot], asOf now: Date = Date()) -> CapacityEstimate? {
guard snapshots.count >= minSampleSize else { return nil }
let percents = snapshots.map(\.percent)
let range = (percents.max() ?? 0) - (percents.min() ?? 0)
guard range >= minPercentRange else { return nil }
let weighted = snapshots.map { snap -> (p: Double, t: Double, w: Double) in
let ageSeconds = now.timeIntervalSince(snap.capturedAt)
let weight = pow(0.5, max(0, ageSeconds) / recencyHalfLifeSeconds)
return (snap.percent, snap.effectiveTokens, weight)
}
// Weighted least squares through origin: minimize sum(w * (t - p * cap/100)^2)
// Solution: cap = 100 * sum(w * t * p) / sum(w * p * p)
let numerator = weighted.reduce(0.0) { $0 + $1.w * $1.t * $1.p }
let denominator = weighted.reduce(0.0) { $0 + $1.w * $1.p * $1.p }
guard denominator > 0 else { return nil }
let capacity = 100.0 * numerator / denominator
guard capacity > 0 else { return nil }
// Weighted R^2 against the through-origin fit.
let weightedTokenSum = weighted.reduce(0.0) { $0 + $1.w * $1.t }
let weightSum = weighted.reduce(0.0) { $0 + $1.w }
let weightedMeanT = weightedTokenSum / max(weightSum, .ulpOfOne)
let ssRes = weighted.reduce(0.0) { acc, s in
let predicted = s.p * capacity / 100
let diff = s.t - predicted
return acc + s.w * diff * diff
}
let ssTot = weighted.reduce(0.0) { acc, s in
let diff = s.t - weightedMeanT
return acc + s.w * diff * diff
}
let r2 = ssTot > 0 ? max(0.0, 1.0 - ssRes / ssTot) : 0.0
let n = snapshots.count
let confidence: CapacityConfidence = {
if n >= solidSampleThreshold && r2 >= solidR2 { return .solid }
if n >= mediumSampleThreshold && r2 >= mediumR2 { return .medium }
return .low
}()
let nonLinearityWarning = detectNonLinearity(snapshots: weighted, capacity: capacity)
return CapacityEstimate(
capacity: capacity,
confidence: confidence,
sampleSize: n,
nonLinearityWarning: nonLinearityWarning
)
}
/// Sign-test on residuals across the percent range. If residuals form a long monotonic run
/// (e.g. all-negative in low percents then all-positive at high), the relationship isn't linear.
private static func detectNonLinearity(
snapshots: [(p: Double, t: Double, w: Double)],
capacity: Double
) -> Bool {
let sorted = snapshots.sorted { $0.p < $1.p }
let signs = sorted.map { s -> Int in
let predicted = s.p * capacity / 100
let diff = s.t - predicted
if abs(diff) < .ulpOfOne { return 0 }
return diff > 0 ? 1 : -1
}.filter { $0 != 0 }
guard signs.count >= minSampleSize else { return false }
// Longest single-sign run length / total
var longestRun = 0
var currentRun = 0
var currentSign = 0
for s in signs {
if s == currentSign {
currentRun += 1
} else {
longestRun = max(longestRun, currentRun)
currentSign = s
currentRun = 1
}
}
longestRun = max(longestRun, currentRun)
let runFraction = Double(longestRun) / Double(signs.count)
return runFraction >= nonLinearityRunLengthThreshold
}
}
@@ -0,0 +1,351 @@
import Foundation
import Security
/// Owns the lifecycle of Claude OAuth credentials, mirroring CodexBar's pattern:
///
/// 1. **Bootstrap is user-initiated.** The first read of Claude's keychain
/// entry which triggers a macOS keychain prompt only happens when
/// the user clicks "Connect" in the Plan tab. The menubar does not
/// touch Claude's keychain on launch.
///
/// 2. **The Claude CLI owns the grant; we never refresh it ourselves.**
/// Claude's refresh token is single-use and rotates on every refresh, and
/// the CLI is refreshing the same grant. If the menubar spent that token
/// it would invalidate the CLI's own login. So on expiry/401 we re-read
/// the CLI's store for a token it has already rotated rather than calling
/// the refresh endpoint. If the CLI hasn't rotated yet we report a
/// transient staleness (`sourceTokenStale`) and recover on its next use.
///
/// 3. **In-memory + file cache** so back-to-back reads in the same refresh
/// cycle don't re-hit the source, and we keep serving the last good token
/// across launches.
enum ClaudeCredentialStore {
private static let bootstrapCompletedKey = "codeburn.claude.bootstrapCompleted"
private static let inMemoryTTL: TimeInterval = 5 * 60
private static let proactiveRefreshMargin: TimeInterval = 5 * 60
private static let claudeKeychainService = "Claude Code-credentials"
private static let credentialsRelativePath = ".claude/.credentials.json"
private static let maxCredentialBytes = 64 * 1024
/// Legacy local cache file. New writes use the macOS Keychain; this path is
private static let cacheFilename = "claude-credentials.v1.json"
private static let lock = NSLock()
private nonisolated(unsafe) static var memoryCache: CachedRecord?
struct CachedRecord {
let record: CredentialRecord
let cachedAt: Date
var isFresh: Bool { Date().timeIntervalSince(cachedAt) < ClaudeCredentialStore.inMemoryTTL }
}
struct CredentialRecord: Codable, Equatable {
let accessToken: String
let refreshToken: String?
let expiresAt: Date?
let rateLimitTier: String?
}
enum StoreError: Error, LocalizedError {
case bootstrapNoSource // neither file nor Claude keychain has credentials
case bootstrapDecodeFailed
case keychainWriteFailed(OSStatus)
case keychainReadFailed(OSStatus)
case noRefreshToken
case sourceTokenStale // CLI hasn't rotated yet; transient, not a re-auth
var errorDescription: String? {
switch self {
case .bootstrapNoSource:
return "No Claude credentials found. Sign in with `claude` first."
case .bootstrapDecodeFailed:
return "Claude credentials are malformed."
case let .keychainWriteFailed(status):
return "Could not write to keychain (status \(status))."
case let .keychainReadFailed(status):
return "Could not read from keychain (status \(status))."
case .noRefreshToken:
return "No refresh token available; reconnect required."
case .sourceTokenStale:
return "Waiting for the Claude CLI to refresh its token."
}
}
/// True when the failure means the user must re-authenticate (re-run
/// `claude` or click Reconnect). Used by the UI to distinguish between
/// "try again later" and "you must act". `sourceTokenStale` is the CLI
/// not having rotated yet transient, recovers on its next use.
var isTerminal: Bool {
if case .noRefreshToken = self { return true }
return false
}
}
// MARK: - Bootstrap state
/// True once the user has explicitly connected (clicked Connect in the Plan
/// tab AND we successfully read their credentials). Persists across launches.
static var isBootstrapCompleted: Bool {
get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) }
set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) }
}
/// Reset bootstrap state. Used when the user explicitly wants to disconnect
/// or when the refresh token has been revoked terminally.
static func resetBootstrap() {
lock.withLock { memoryCache = nil }
deleteOurCache()
isBootstrapCompleted = false
}
// MARK: - Public API
/// User-initiated entry point. Reads from Claude's source (PROMPTS for the
/// keychain on first use), writes to our own keychain item, marks bootstrap
/// as completed.
@discardableResult
static func bootstrap() throws -> CredentialRecord {
let record = try readClaudeSource()
try writeOurCache(record: record)
isBootstrapCompleted = true
cacheInMemory(record)
return record
}
/// Silent read for background refresh cycles. Reads only from our cache /
/// keychain item never prompts. Returns nil if not bootstrapped.
static func currentRecord() throws -> CredentialRecord? {
guard isBootstrapCompleted else { return nil }
// Honour the in-memory TTL: a stale cached record can mask a token
// that another process (e.g. claude /login again) has just rotated
// on disk. Re-read the file when the cache passes the TTL.
if let cached = lock.withLock({ memoryCache }), cached.isFresh {
return cached.record
}
if let stored = try readOurCache() {
cacheInMemory(stored)
return stored
}
// Bootstrap flag is set but our cache file is missing most likely
// a fresh install resetting state, or the user manually deleted the
// file. Force re-bootstrap on next user action.
isBootstrapCompleted = false
return nil
}
/// Returns the current token, adopting a fresher one from the CLI's store if
/// ours is near expiry. Never spends the refresh token see the type doc.
static func freshAccessToken() async throws -> String? {
guard let record = try currentRecord() else { return nil }
if let expiresAt = record.expiresAt, expiresAt.timeIntervalSinceNow < proactiveRefreshMargin {
if let live = adoptFresherSource(than: record) {
return live.accessToken
}
}
return record.accessToken
}
/// Called after an explicit 401. Delegates to the CLI: re-reads its store
/// (silently, no prompt) for a token it has already rotated. If none is
/// available yet, throws the transient `sourceTokenStale` rather than
/// spending the shared refresh token, which would break the CLI's login.
static func refreshAfter401() async throws -> String {
guard let record = try currentRecord() else { throw StoreError.noRefreshToken }
if let live = adoptFresherSource(than: record) {
return live.accessToken
}
throw StoreError.sourceTokenStale
}
/// Re-reads Claude's own store (file, then keychain with a no-UI query) and
/// adopts it when it holds a different access token than `record` i.e. the
/// CLI rotated since we last read. Returns nil when nothing fresher exists.
private static func adoptFresherSource(than record: CredentialRecord) -> CredentialRecord? {
guard let live = readClaudeSourceSilently(), live.accessToken != record.accessToken else {
return nil
}
cacheInMemory(live)
try? writeOurCache(record: live)
return live
}
private static func readClaudeSourceSilently() -> CredentialRecord? {
if let fromFile = try? readClaudeFile() { return fromFile }
if let fromKeychain = try? readClaudeKeychain(allowUI: false) { return fromKeychain }
return nil
}
static func subscriptionTier() throws -> String? {
try currentRecord()?.rateLimitTier
}
// MARK: - Bootstrap source
private static func readClaudeSource() throws -> CredentialRecord {
if let fromFile = try? readClaudeFile() { return fromFile }
if let fromKeychain = try readClaudeKeychain(allowUI: true) { return fromKeychain }
throw StoreError.bootstrapNoSource
}
private static func readClaudeFile() throws -> CredentialRecord? {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath)
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
return try parseClaudeBlob(data: sanitizeClaudeBlob(data))
}
/// Reads Claude's keychain credentials. The CLI has historically written
/// entries under different account names older versions used "agentseal"
/// (a hardcoded company-style identifier) while Claude Code 2.1.x writes
/// under `$USER` (NSUserName()). After a user re-runs `/login`, both
/// entries can coexist and a service-only lookup often returns the older
/// stale one. We try the user-keyed entry first (the modern format), then
/// fall back to the unscoped query for older installations.
///
/// Silent background reads go through the `security` CLI rather than the
/// Security framework. The Apple-signed `security` binary sits in the
/// keychain item's `apple-tool:` partition, so it never raises the
/// partition-list prompt. The framework API does and re-prompts every
/// time Claude Code rotates its credential and resets the item's partition
/// list, dropping our app from the allowed set (issue #490). Only the
/// user-initiated bootstrap still reads through the framework, where a
/// single consent prompt is expected.
private static func readClaudeKeychain(allowUI: Bool) throws -> CredentialRecord? {
if !allowUI {
return readClaudeKeychainSilently(account: NSUserName())
?? readClaudeKeychainSilently(account: nil)
}
if let record = try readClaudeKeychainPrompting(account: NSUserName()) {
return record
}
return try readClaudeKeychainPrompting(account: nil)
}
private static func readClaudeKeychainPrompting(account: String?) throws -> CredentialRecord? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: claudeKeychainService,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
if let account { query[kSecAttrAccount as String] = account }
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 parseClaudeBlob(data: sanitizeClaudeBlob(data))
}
/// Reads Claude's keychain entry via `/usr/bin/security`, which never raises
/// the partition-list prompt. Returns nil on any failure so the caller falls
/// back to the cached token.
private static func readClaudeKeychainSilently(account: String?) -> CredentialRecord? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
var args = ["find-generic-password", "-s", claudeKeychainService]
if let account { args += ["-a", account] }
args.append("-w")
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return nil }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return try? parseClaudeBlob(data: sanitizeClaudeBlob(data))
} catch {
return nil
}
}
/// Claude Code's keychain writer line-wraps long values (newline + leading
/// spaces) mid-token, producing JSON with literal control chars inside string
/// values. Strip those plus pretty-print indentation between fields so the
/// JSON parser succeeds.
private static func sanitizeClaudeBlob(_ data: Data) -> Data {
guard var s = String(data: data, encoding: .utf8) else { return data }
s = s.replacingOccurrences(of: "\r", with: "")
if let regex = try? NSRegularExpression(pattern: "\\n[ \\t]*", options: []) {
let range = NSRange(s.startIndex..<s.endIndex, in: s)
s = regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "")
}
s = s.trimmingCharacters(in: .whitespacesAndNewlines)
return s.data(using: .utf8) ?? data
}
private static func parseClaudeBlob(data: Data) throws -> CredentialRecord {
struct Root: Decodable { let claudeAiOauth: OAuth? }
struct OAuth: Decodable {
let accessToken: String?
let refreshToken: String?
let expiresAt: Double?
let rateLimitTier: String?
}
do {
let root = try JSONDecoder().decode(Root.self, from: data)
guard let oauth = root.claudeAiOauth,
let token = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
else { throw StoreError.bootstrapDecodeFailed }
return CredentialRecord(
accessToken: token,
refreshToken: oauth.refreshToken,
expiresAt: oauth.expiresAt.map { Date(timeIntervalSince1970: $0 / 1000.0) },
rateLimitTier: oauth.rateLimitTier
)
} catch {
throw StoreError.bootstrapDecodeFailed
}
}
// MARK: - Local cache file (no keychain involvement)
private static func cacheFileURL() -> URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
return support
.appendingPathComponent("CodeBurn", isDirectory: true)
.appendingPathComponent(cacheFilename)
}
private static func readOurCache() throws -> CredentialRecord? {
let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil }
return record
}
private static func writeOurCache(record: CredentialRecord) throws {
try writeOurFileCache(record: record)
}
private static func writeOurFileCache(record: CredentialRecord) throws {
let url = cacheFileURL()
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let data = try JSONEncoder().encode(record)
try data.write(to: url, options: [.atomic, .completeFileProtection])
}
private static func deleteOurCache() {
try? FileManager.default.removeItem(at: cacheFileURL())
}
private static func cacheInMemory(_ record: CredentialRecord) {
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) }
}
}
private extension NSLock {
func withLock<T>(_ body: () throws -> T) rethrows -> T {
lock(); defer { unlock() }
return try body()
}
}
@@ -0,0 +1,285 @@
import Foundation
/// Orchestrates "given a credential record, fetch live quota from Anthropic
/// and surface a result the UI can render". All token persistence lives in
/// `ClaudeCredentialStore`; the only state this service holds is the
/// 429 backoff window for the usage endpoint.
enum ClaudeSubscriptionService {
private static let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")!
private static let betaHeader = "oauth-2025-04-20"
private static let userAgent = "claude-code/2.1.0"
private static let usageBlockedUntilKey = "codeburn.claude.usage.blockedUntil"
enum FetchError: Error, LocalizedError {
case notBootstrapped
case bootstrapFailed(ClaudeCredentialStore.StoreError)
case rateLimited(retryAt: Date)
case usageHTTPError(Int, String?)
case usageDecodeFailed
case network(Error)
case credential(ClaudeCredentialStore.StoreError)
var errorDescription: String? {
switch self {
case .notBootstrapped:
return "Connect Claude in the Plan tab to start tracking quota."
case let .bootstrapFailed(err):
return err.errorDescription
case let .rateLimited(retryAt):
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return "Anthropic rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
case let .usageHTTPError(code, body):
return "Quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
case .usageDecodeFailed:
return "Quota response was malformed."
case let .network(err):
return "Network error: \(err.localizedDescription)"
case let .credential(err):
return err.errorDescription
}
}
/// True when the user must take action (re-run claude/login or click
/// Reconnect). Drives the red "Reconnect" UI path.
var isTerminal: Bool {
if case let .credential(err) = self { return err.isTerminal }
if case let .bootstrapFailed(err) = self { return err.isTerminal }
return false
}
var rateLimitRetryAt: Date? {
if case let .rateLimited(retryAt) = self { return retryAt }
return nil
}
}
// MARK: - Public API
/// User-initiated. Reads Claude's keychain (PROMPTS), copies to our keychain,
/// then fetches usage. Idempotent safe to call again to "reconnect".
static func bootstrap() async throws -> SubscriptionUsage {
// Honour the same 429 backoff that refreshIfBootstrapped respects.
// Without this, a user spamming Reconnect during a sustained
// rate-limit window hammers Anthropic on every click exactly the
// pattern that escalates the backoff.
if let until = usageBlockedUntil(), until > Date() {
throw FetchError.rateLimited(retryAt: until)
}
let record: ClaudeCredentialStore.CredentialRecord
do {
record = try ClaudeCredentialStore.bootstrap()
} catch let err as ClaudeCredentialStore.StoreError {
throw FetchError.bootstrapFailed(err)
}
return try await fetchWithRecord(initial: record)
}
/// Background refresh. Never prompts. Returns nil if not yet bootstrapped.
static func refreshIfBootstrapped() async throws -> SubscriptionUsage? {
guard ClaudeCredentialStore.isBootstrapCompleted else {
return nil
}
// Honour an outstanding rate-limit window we recorded a 429 recently
// and Anthropic told us when to come back.
if let until = usageBlockedUntil(), until > Date() {
throw FetchError.rateLimited(retryAt: until)
}
do {
let token = try await ClaudeCredentialStore.freshAccessToken()
guard let token else { throw FetchError.notBootstrapped }
return try await fetch(token: token, allowOne401Recovery: true)
} catch let err as ClaudeCredentialStore.StoreError {
throw FetchError.credential(err)
} catch let err as FetchError {
throw err
}
}
/// Reset everything used on user-initiated disconnect.
static func disconnect() {
ClaudeCredentialStore.resetBootstrap()
clearUsageBlock()
}
// MARK: - Internal
private static func fetchWithRecord(initial record: ClaudeCredentialStore.CredentialRecord) async throws -> SubscriptionUsage {
do {
return try await fetch(token: record.accessToken, allowOne401Recovery: true)
} catch let err as FetchError {
throw err
} catch let err as ClaudeCredentialStore.StoreError {
throw FetchError.credential(err)
}
}
private static func fetch(token: String, allowOne401Recovery: Bool) async throws -> SubscriptionUsage {
var request = URLRequest(url: usageURL)
request.httpMethod = "GET"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta")
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw FetchError.network(error)
}
guard let http = response as? HTTPURLResponse else {
throw FetchError.usageHTTPError(-1, nil)
}
switch http.statusCode {
case 200:
clearUsageBlock()
do {
let tier = try ClaudeCredentialStore.subscriptionTier()
return try parseUsage(data, rawTier: tier)
} catch {
throw FetchError.usageDecodeFailed
}
case 401:
if allowOne401Recovery {
let newToken = try await ClaudeCredentialStore.refreshAfter401()
return try await fetch(token: newToken, allowOne401Recovery: false)
}
throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8))
case 429:
let body = String(data: data, encoding: .utf8)
let retryAfter = parseRetryAfter(body: body)
let until = recordUsageRateLimit(retryAfterSeconds: retryAfter)
throw FetchError.rateLimited(retryAt: until)
default:
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
}
}
// MARK: - 429 backoff
private static func usageBlockedUntil() -> Date? {
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
}
private static func clearUsageBlock() {
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
}
@discardableResult
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
let seconds = max(retryAfterSeconds ?? 300, 60)
let until = Date().addingTimeInterval(TimeInterval(seconds))
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
return until
}
private static func parseRetryAfter(body: String?) -> Int? {
guard let body, let data = body.data(using: .utf8) else { return nil }
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let n = json["retry_after"] as? Int { return n }
if let s = json["retry_after"] as? String, let n = Int(s) { return n }
}
return nil
}
// MARK: - Response mapping
/// Decodes a usage endpoint response body. Internal so tests can feed the
/// captured JSON shape without a network round trip.
static func parseUsage(_ data: Data, rawTier: String?) throws -> SubscriptionUsage {
let decoded = try JSONDecoder().decode(UsageResponse.self, from: data)
return mapResponse(decoded, rawTier: rawTier)
}
private struct UsageResponse: Decodable {
let fiveHour: Window?
let sevenDay: Window?
let sevenDayOpus: Window?
let sevenDaySonnet: Window?
let limits: [Limit]?
enum CodingKeys: String, CodingKey {
case fiveHour = "five_hour"
case sevenDay = "seven_day"
case sevenDayOpus = "seven_day_opus"
case sevenDaySonnet = "seven_day_sonnet"
case limits
}
}
private struct Window: Decodable {
let utilization: Double?
let resetsAt: String?
enum CodingKeys: String, CodingKey {
case utilization
case resetsAt = "resets_at"
}
}
/// Entry in the `limits` array. Model-scoped weekly buckets (like Fable)
/// only appear here, not as named top-level windows.
private struct Limit: Decodable {
let kind: String?
let percent: Double?
let resetsAt: String?
let scope: Scope?
enum CodingKeys: String, CodingKey {
case kind, percent, scope
case resetsAt = "resets_at"
}
struct Scope: Decodable {
let model: Model?
struct Model: Decodable {
let displayName: String?
enum CodingKeys: String, CodingKey {
case displayName = "display_name"
}
}
}
}
private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage {
let scopedWeekly = (r.limits ?? []).compactMap { limit -> SubscriptionUsage.ScopedWindow? in
guard limit.kind == "weekly_scoped",
let name = limit.scope?.model?.displayName,
let percent = limit.percent
else { return nil }
return SubscriptionUsage.ScopedWindow(
label: name,
percent: percent,
resetsAt: parseDate(limit.resetsAt)
)
}
return SubscriptionUsage(
tier: SubscriptionUsage.tier(from: rawTier),
rawTier: rawTier,
fiveHourPercent: r.fiveHour?.utilization,
fiveHourResetsAt: parseDate(r.fiveHour?.resetsAt),
sevenDayPercent: r.sevenDay?.utilization,
sevenDayResetsAt: parseDate(r.sevenDay?.resetsAt),
sevenDayOpusPercent: r.sevenDayOpus?.utilization,
sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt),
sevenDaySonnetPercent: r.sevenDaySonnet?.utilization,
sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt),
scopedWeekly: scopedWeekly,
fetchedAt: Date()
)
}
private static func parseDate(_ s: String?) -> Date? {
guard let s, !s.isEmpty else { return nil }
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = f.date(from: s) { return d }
f.formatOptions = [.withInternetDateTime]
return f.date(from: s)
}
}
@@ -0,0 +1,364 @@
import Foundation
import Security
/// Owns the Codex (ChatGPT-mode) OAuth credential lifecycle. Mirrors
/// ClaudeCredentialStore but reads from ~/.codex/auth.json Codex CLI
/// already stores its tokens as plaintext JSON in the home directory, so
/// no keychain prompt is involved on bootstrap. After the user clicks
/// Connect we cache a copy under ~/Library/Application Support/CodeBurn so
/// we keep using rotated tokens after refresh.
enum CodexCredentialStore {
private static let bootstrapCompletedKey = "codeburn.codex.bootstrapCompleted"
private static let inMemoryTTL: TimeInterval = 5 * 60
// Codex refresh tokens are single-use and rotate on every refresh. The CLI
// owns the grant via ~/.codex/auth.json; we only refresh ourselves when its
// last_refresh is older than this, mirroring the Codex CLI's own cadence.
// Refreshing more eagerly races the CLI and burns its rotating token.
private static let staleRefreshInterval: TimeInterval = 8 * 24 * 60 * 60
private static let oauthClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
private static let refreshURL = URL(string: "https://auth.openai.com/oauth/token")!
private static let codexAuthPath = ".codex/auth.json"
private static let maxCredentialBytes = 64 * 1024
private static let cacheFilename = "codex-credentials.v1.json"
private static let lock = NSLock()
private nonisolated(unsafe) static var memoryCache: CachedRecord?
struct CachedRecord {
let record: CredentialRecord
let cachedAt: Date
var isFresh: Bool { Date().timeIntervalSince(cachedAt) < CodexCredentialStore.inMemoryTTL }
}
struct CredentialRecord: Codable, Equatable {
let accessToken: String
let refreshToken: String
let idToken: String?
let accountId: String?
let expiresAt: Date?
let lastRefresh: Date?
}
enum StoreError: Error, LocalizedError {
case bootstrapNoSource
case bootstrapDecodeFailed
case bootstrapNotChatGPT // user is on API-key mode; we need ChatGPT mode for quota
case fileWriteFailed(String)
case refreshHTTPError(Int, String?)
case refreshNetworkError(Error)
case refreshDecodeFailed
case noRefreshToken
var errorDescription: String? {
switch self {
case .bootstrapNoSource:
return "No Codex credentials found at ~/.codex/auth.json. Run `codex` to sign in."
case .bootstrapDecodeFailed:
return "Codex credentials are malformed."
case .bootstrapNotChatGPT:
return "Codex is in API-key mode; live quota tracking is only available for ChatGPT subscriptions."
case let .fileWriteFailed(message):
return "Could not write to local cache: \(message)"
case let .refreshHTTPError(code, body):
return "Codex token refresh failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
case let .refreshNetworkError(err):
return "Codex token refresh network error: \(err.localizedDescription)"
case .refreshDecodeFailed:
return "Codex token refresh response was malformed."
case .noRefreshToken:
return "No refresh token available; reconnect required."
}
}
/// True when the user must take action: rerun `codex` to re-authenticate
/// or switch from API-key to ChatGPT mode. Drives the red Reconnect path.
var isTerminal: Bool {
if case let .refreshHTTPError(code, body) = self, code >= 400, code < 500 {
let lower = body?.lowercased() ?? ""
if lower.contains("refresh_token_expired") ||
lower.contains("refresh_token_reused") ||
lower.contains("refresh_token_invalidated") ||
lower.contains("invalid_grant")
{
return true
}
return true
}
switch self {
case .noRefreshToken, .bootstrapNotChatGPT, .bootstrapNoSource: return true
default: return false
}
}
}
// MARK: - Bootstrap state
static var isBootstrapCompleted: Bool {
get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) }
set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) }
}
static func resetBootstrap() {
lock.withLock { memoryCache = nil }
deleteOurCache()
isBootstrapCompleted = false
}
// MARK: - Public API
@discardableResult
static func bootstrap() throws -> CredentialRecord {
let record = try readCodexAuth()
try writeOurCache(record: record)
isBootstrapCompleted = true
cacheInMemory(record)
return record
}
static func currentRecord() throws -> CredentialRecord? {
guard isBootstrapCompleted else { return nil }
// The Codex CLI's auth.json is the source of truth. Read it fresh each
// call so we always serve the CLI's current token rather than racing it
// with a stale private copy. Our cache is only a fallback for when the
// file is briefly unreadable.
if let live = try? readCodexAuth() {
cacheInMemory(live)
try? writeOurCache(record: live)
return live
}
if let cached = lock.withLock({ memoryCache }), cached.isFresh {
return cached.record
}
if let stored = try readOurCache() {
cacheInMemory(stored)
return stored
}
return nil
}
static func freshAccessToken() async throws -> String? {
guard let record = try currentRecord() else { return nil }
if needsRefresh(record) {
let updated = try await refreshAndPersist(record: record)
return updated.accessToken
}
return record.accessToken
}
static func refreshAfter401(failedToken: String) async throws -> String {
// Source of truth first: the CLI may have already rotated the token out
// from under us. Re-read auth.json before spending our single-use refresh
// token, which would race the CLI and can invalidate its login.
if let live = try? readCodexAuth(), live.accessToken != failedToken {
cacheInMemory(live)
try? writeOurCache(record: live)
return live.accessToken
}
guard let record = try currentRecord() else { throw StoreError.noRefreshToken }
let updated = try await refreshAndPersist(record: record)
return updated.accessToken
}
private static func needsRefresh(_ record: CredentialRecord) -> Bool {
guard let last = record.lastRefresh else { return true }
return Date().timeIntervalSince(last) > staleRefreshInterval
}
// MARK: - Bootstrap source: ~/.codex/auth.json
private static func readCodexAuth() throws -> CredentialRecord {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(codexAuthPath)
guard FileManager.default.fileExists(atPath: url.path) else {
throw StoreError.bootstrapNoSource
}
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
struct Root: Decodable {
let auth_mode: String?
let tokens: Tokens?
let last_refresh: String?
}
struct Tokens: Decodable {
let access_token: String?
let refresh_token: String?
let id_token: String?
let account_id: String?
}
do {
let root = try JSONDecoder().decode(Root.self, from: data)
// Live quota is only meaningful for ChatGPT-mode auth. API-key users
// have a different billing surface (/v1/usage) which we do not yet
// implement here.
guard root.auth_mode == "chatgpt" else {
throw StoreError.bootstrapNotChatGPT
}
guard let tokens = root.tokens,
let access = tokens.access_token?.trimmingCharacters(in: .whitespacesAndNewlines),
let refresh = tokens.refresh_token?.trimmingCharacters(in: .whitespacesAndNewlines),
!access.isEmpty, !refresh.isEmpty
else {
throw StoreError.bootstrapDecodeFailed
}
return CredentialRecord(
accessToken: access,
refreshToken: refresh,
idToken: tokens.id_token,
accountId: tokens.account_id,
expiresAt: nil, // Codex CLI does not record expiresAt in auth.json
lastRefresh: root.last_refresh.flatMap(parseISO8601)
)
} catch let err as StoreError {
throw err
} catch {
throw StoreError.bootstrapDecodeFailed
}
}
private static func parseISO8601(_ s: String) -> Date? {
// auth.json records fractional seconds (e.g. ...:12.010758Z); the plain
// ISO8601 formatter rejects those, so try the fractional variant first.
let withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = withFraction.date(from: s) { return d }
return ISO8601DateFormatter().date(from: s)
}
// MARK: - Write rotated tokens back to ~/.codex/auth.json
/// Atomic read-modify-write of auth.json that preserves every other top-level
/// key (OPENAI_API_KEY, auth_mode, ...) and only rewrites the tokens dict and
/// last_refresh. Keeps the CLI and the menubar on the same rotated grant.
private static func writeBackToCodexAuth(record: CredentialRecord) {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(codexAuthPath)
var json: [String: Any] = [:]
if let data = try? SafeFile.read(from: url.path, maxBytes: maxCredentialBytes),
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
json = existing
}
var tokens: [String: Any] = [
"access_token": record.accessToken,
"refresh_token": record.refreshToken,
]
if let idToken = record.idToken { tokens["id_token"] = idToken }
if let accountId = record.accountId { tokens["account_id"] = accountId }
json["tokens"] = tokens
let stamp = ISO8601DateFormatter()
stamp.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
json["last_refresh"] = stamp.string(from: record.lastRefresh ?? Date())
guard let out = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) else {
return
}
try? out.write(to: url, options: .atomic)
}
// MARK: - Local cache file
private static func cacheFileURL() -> URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
return support
.appendingPathComponent("CodeBurn", isDirectory: true)
.appendingPathComponent(cacheFilename)
}
private static func readOurCache() throws -> CredentialRecord? {
let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil }
return record
}
private static func writeOurCache(record: CredentialRecord) throws {
try writeOurFileCache(record: record)
}
private static func writeOurFileCache(record: CredentialRecord) throws {
let url = cacheFileURL()
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let data = try JSONEncoder().encode(record)
try data.write(to: url, options: [.atomic, .completeFileProtection])
}
private static func deleteOurCache() {
try? FileManager.default.removeItem(at: cacheFileURL())
}
private static func cacheInMemory(_ record: CredentialRecord) {
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) }
}
// MARK: - Refresh
private static func refreshAndPersist(record: CredentialRecord) async throws -> CredentialRecord {
guard !record.refreshToken.isEmpty else { throw StoreError.noRefreshToken }
var request = URLRequest(url: refreshURL)
request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: String] = [
"client_id": oauthClientID,
"grant_type": "refresh_token",
"refresh_token": record.refreshToken,
"scope": "openid profile email",
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw StoreError.refreshNetworkError(error)
}
guard let http = response as? HTTPURLResponse else {
throw StoreError.refreshHTTPError(-1, nil)
}
guard http.statusCode == 200 else {
// A 4xx here usually means the CLI already rotated the shared grant
// out from under us (single-use refresh token). Re-read the source:
// if it now holds a different token, the CLI healed it and we adopt
// that instead of surfacing a terminal "disconnected".
if http.statusCode >= 400, http.statusCode < 500,
let live = try? readCodexAuth(), live.refreshToken != record.refreshToken {
cacheInMemory(live)
try? writeOurCache(record: live)
return live
}
let body = String(data: data, encoding: .utf8)
throw StoreError.refreshHTTPError(http.statusCode, body)
}
struct RefreshResponse: Decodable {
let access_token: String
let refresh_token: String?
let id_token: String?
let expires_in: Int?
}
guard let decoded = try? JSONDecoder().decode(RefreshResponse.self, from: data) else {
throw StoreError.refreshDecodeFailed
}
let updated = CredentialRecord(
accessToken: decoded.access_token,
refreshToken: decoded.refresh_token ?? record.refreshToken,
idToken: decoded.id_token ?? record.idToken,
accountId: record.accountId,
expiresAt: decoded.expires_in.map { Date().addingTimeInterval(TimeInterval($0)) } ?? record.expiresAt,
lastRefresh: Date()
)
cacheInMemory(updated)
// Write the rotated grant back to the CLI's store first so the CLI keeps
// working, then mirror it into our fallback cache.
writeBackToCodexAuth(record: updated)
do {
try writeOurCache(record: updated)
} catch {
NSLog("CodeBurn: codex cache write failed during refresh rotation: %@", String(describing: error))
}
return updated
}
}
@@ -0,0 +1,243 @@
import Foundation
/// Mirror of ClaudeSubscriptionService for Codex (ChatGPT-mode). Hits
/// /backend-api/wham/usage with the bearer token from CodexCredentialStore,
/// applies an independent 429 backoff, and surfaces terminal vs transient
/// failures to the UI.
enum CodexSubscriptionService {
private static let usageURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")!
private static let usageBlockedUntilKey = "codeburn.codex.usage.blockedUntil"
enum FetchError: Error, LocalizedError {
case notBootstrapped
case bootstrapFailed(CodexCredentialStore.StoreError)
case rateLimited(retryAt: Date)
case usageHTTPError(Int, String?)
case usageDecodeFailed
case network(Error)
case credential(CodexCredentialStore.StoreError)
var errorDescription: String? {
switch self {
case .notBootstrapped:
return "Connect Codex in Settings to start tracking quota."
case let .bootstrapFailed(err): return err.errorDescription
case let .rateLimited(retryAt):
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return "ChatGPT rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
case let .usageHTTPError(code, body):
return "Codex quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
case .usageDecodeFailed: return "Codex quota response was malformed."
case let .network(err): return "Network error: \(err.localizedDescription)"
case let .credential(err): return err.errorDescription
}
}
var isTerminal: Bool {
if case let .credential(err) = self { return err.isTerminal }
if case let .bootstrapFailed(err) = self { return err.isTerminal }
return false
}
var rateLimitRetryAt: Date? {
if case let .rateLimited(retryAt) = self { return retryAt }
return nil
}
}
static func bootstrap() async throws -> CodexUsage {
// Honour the same 429 backoff that refreshIfBootstrapped respects.
// A user clicking Reconnect during a sustained ChatGPT rate-limit
// window would otherwise re-hit /wham/usage on every click and keep
// the backoff window pegged.
if let until = usageBlockedUntil(), until > Date() {
throw FetchError.rateLimited(retryAt: until)
}
let record: CodexCredentialStore.CredentialRecord
do {
record = try CodexCredentialStore.bootstrap()
} catch let err as CodexCredentialStore.StoreError {
throw FetchError.bootstrapFailed(err)
}
return try await fetchWithToken(record.accessToken, allowOne401Recovery: true)
}
static func refreshIfBootstrapped() async throws -> CodexUsage? {
guard CodexCredentialStore.isBootstrapCompleted else { return nil }
if let until = usageBlockedUntil(), until > Date() {
throw FetchError.rateLimited(retryAt: until)
}
do {
let token = try await CodexCredentialStore.freshAccessToken()
guard let token else { throw FetchError.notBootstrapped }
return try await fetchWithToken(token, allowOne401Recovery: true)
} catch let err as CodexCredentialStore.StoreError {
throw FetchError.credential(err)
}
}
static func disconnect() {
CodexCredentialStore.resetBootstrap()
clearUsageBlock()
}
private static func fetchWithToken(_ token: String, allowOne401Recovery: Bool) async throws -> CodexUsage {
var request = URLRequest(url: usageURL)
request.httpMethod = "GET"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
// chatgpt.com routes the rate_limit envelope per ChatGPT account. Without
// this header the response often comes back as a guest-shape document
// missing rate_limit entirely, which our decoder then fails on.
if let accountId = try? CodexCredentialStore.currentRecord()?.accountId, !accountId.isEmpty {
request.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-Id")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw FetchError.network(error)
}
guard let http = response as? HTTPURLResponse else {
throw FetchError.usageHTTPError(-1, nil)
}
switch http.statusCode {
case 200:
clearUsageBlock()
do {
return try decodeUsage(data: data)
} catch {
// Do not log the response body it's user-account data from
// chatgpt.com and is readable by other local users via
// `log stream`. The decode error type alone is enough to
// bisect schema drift if needed.
NSLog("CodeBurn: codex usage decode failed: %@", String(describing: error))
throw FetchError.usageDecodeFailed
}
case 401:
if allowOne401Recovery {
let newToken = try await CodexCredentialStore.refreshAfter401(failedToken: token)
return try await fetchWithToken(newToken, allowOne401Recovery: false)
}
throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8))
case 429:
// Honour the RFC Retry-After header when present ChatGPT's quota
// endpoint sometimes sets it to a window shorter than our 5-min
// floor, and ignoring it forced users to wait longer than the
// server actually wanted.
let retryAfter = parseRetryAfterHeader(http.value(forHTTPHeaderField: "Retry-After"))
let until = recordUsageRateLimit(retryAfterSeconds: retryAfter)
throw FetchError.rateLimited(retryAt: until)
default:
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
}
}
private struct UsageDTO: Decodable {
let plan_type: String?
let rate_limit: RateLimit?
let additional_rate_limits: [AdditionalLimitDTO]?
let credits: Credits?
struct RateLimit: Decodable {
let primary_window: WindowDTO?
let secondary_window: WindowDTO?
}
struct AdditionalLimitDTO: Decodable {
let limit_name: String?
let rate_limit: RateLimit?
}
struct WindowDTO: Decodable {
let used_percent: Double?
let reset_at: Int?
let limit_window_seconds: Int?
}
// chatgpt.com sometimes serializes balance as a Double ("balance": 0.0)
// and other times as a String ("balance": "0.00"). Mirror CodexBar's
// resilient decode so a schema drift on either shape doesn't blow up
// the whole quota fetch.
struct Credits: Decodable {
let balance: Double?
enum CodingKeys: String, CodingKey { case balance }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
if let n = try? c.decode(Double.self, forKey: .balance) {
balance = n
} else if let s = try? c.decode(String.self, forKey: .balance), let n = Double(s) {
balance = n
} else {
balance = nil
}
}
}
}
private static func decodeUsage(data: Data) throws -> CodexUsage {
let root = try JSONDecoder().decode(UsageDTO.self, from: data)
let additional: [CodexUsage.AdditionalLimit] = (root.additional_rate_limits ?? []).compactMap { dto in
guard let name = dto.limit_name, !name.isEmpty else { return nil }
return CodexUsage.AdditionalLimit(
name: name,
primary: makeWindow(dto.rate_limit?.primary_window),
secondary: makeWindow(dto.rate_limit?.secondary_window)
)
}
return CodexUsage(
plan: CodexUsage.planType(from: root.plan_type),
primary: makeWindow(root.rate_limit?.primary_window),
secondary: makeWindow(root.rate_limit?.secondary_window),
additionalLimits: additional,
creditsBalance: root.credits?.balance,
fetchedAt: Date()
)
}
private static func makeWindow(_ dto: UsageDTO.WindowDTO?) -> CodexUsage.Window? {
guard let dto, let used = dto.used_percent, let windowSeconds = dto.limit_window_seconds else {
return nil
}
let resetsAt = dto.reset_at.map { Date(timeIntervalSince1970: TimeInterval($0)) }
return CodexUsage.Window(usedPercent: used, resetsAt: resetsAt, limitWindowSeconds: windowSeconds)
}
// MARK: - 429 backoff
private static func usageBlockedUntil() -> Date? {
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
}
private static func clearUsageBlock() {
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
}
@discardableResult
/// RFC 7231 says Retry-After is either a delta-seconds or an HTTP-date.
/// chatgpt.com appears to send delta-seconds today; we still parse both
/// shapes defensively so a future change to HTTP-date doesn't drop us
/// onto the silent 5-minute floor.
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
if let seconds = Int(value), seconds >= 0 { return seconds }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(secondsFromGMT: 0)
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
if let date = f.date(from: value) {
return max(0, Int(date.timeIntervalSinceNow))
}
return nil
}
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
let seconds = max(retryAfterSeconds ?? 300, 60)
let until = Date().addingTimeInterval(TimeInterval(seconds))
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
return until
}
}
@@ -0,0 +1,98 @@
import Foundation
/// Codex (ChatGPT-mode) live quota snapshot returned by /backend-api/wham/usage.
/// Two windows are exposed: primary (typically the 5-hour rolling window) and
/// secondary (typically the weekly window). Window size is dynamic per
/// account `limitWindowSeconds` tells us whether it's a 5-hour or 7-day
/// boundary so we can label correctly.
struct CodexUsage: Sendable, Equatable {
enum PlanType: Sendable, Equatable {
case guest, free, go, plus, pro, prolite, freeWorkspace, team
case business, education, quorum, k12, enterprise, edu
/// Captures any plan_type string OpenAI ships that we haven't enumerated
/// yet, so the Settings/Plan UI can still show "Plan: <raw>" instead of
/// a generic "Subscription" placeholder. Preserves forward compatibility
/// without requiring a CodeBurn update for every new tier.
case unknown(String)
var displayName: String {
switch self {
case .guest: "Guest"
case .free: "Free"
case .go: "Go"
case .plus: "Plus"
case .pro: "Pro"
case .prolite: "Pro Lite"
case .freeWorkspace: "Free Workspace"
case .team: "Team"
case .business: "Business"
case .education: "Education"
case .quorum: "Quorum"
case .k12: "K-12"
case .enterprise: "Enterprise"
case .edu: "Edu"
case let .unknown(raw): raw.isEmpty ? "Subscription" : raw.capitalized
}
}
}
struct Window: Sendable, Equatable {
let usedPercent: Double // 0.0 ... 100.0
let resetsAt: Date?
let limitWindowSeconds: Int
/// Human label inferred from window size: 5h, 1d, 7d, etc.
var windowLabel: String {
switch limitWindowSeconds {
case 0..<3600: return "Hourly"
case 3600..<7200: return "Hour"
case 18000..<19000: return "5-hour"
case 86400..<87000: return "Daily"
case 604800..<605000: return "Weekly"
default:
let hours = limitWindowSeconds / 3600
if hours < 24 { return "\(hours)-hour" }
return "\(hours / 24)-day"
}
}
}
/// Additional per-model / per-feature quotas exposed by ChatGPT alongside
/// the main rate_limit (e.g. "GPT-5.3-Codex-Spark"). Each entry has its
/// own primary/secondary windows. Only ones with non-zero utilization are
/// surfaced in the popover so users on plans that don't touch these
/// features don't see clutter.
struct AdditionalLimit: Sendable, Equatable {
let name: String
let primary: Window?
let secondary: Window?
}
let plan: PlanType
let primary: Window?
let secondary: Window?
let additionalLimits: [AdditionalLimit]
let creditsBalance: Double?
let fetchedAt: Date
static func planType(from raw: String?) -> PlanType {
guard let raw = raw?.lowercased() else { return .unknown("") }
switch raw {
case "guest": return .guest
case "free": return .free
case "go": return .go
case "plus": return .plus
case "pro": return .pro
case "prolite", "pro_lite", "pro-lite": return .prolite
case "free_workspace": return .freeWorkspace
case "team": return .team
case "business": return .business
case "education": return .education
case "quorum": return .quorum
case "k12": return .k12
case "enterprise": return .enterprise
case "edu": return .edu
default: return .unknown(raw)
}
}
}
@@ -0,0 +1,294 @@
import Foundation
/// Upper bound on payload + stderr bytes read from the CLI. Real payloads top out near 500 KB
/// (365 days of history with dozens of models); anything larger is pathological and truncating
/// prevents unbounded memory growth. Hard timeout guards against a hung CLI keeping Process and
/// Pipe file descriptors pinned forever.
private let maxPayloadBytes = 20 * 1024 * 1024
private let maxStderrBytes = 256 * 1024
private let spawnTimeoutSeconds: UInt64 = 45
private let maxConcurrentSpawns = 6
enum DataClientError: Error {
case spawn(String)
case nonZeroExit(code: Int32, stderr: String)
case decode(Error)
case timeout
case outputTooLarge
}
/// Wraps a `MenubarPayload` decode failure with a bounded snippet of what the CLI
/// actually wrote to stdout (plus stderr), so a malformed-output failure for
/// example a stray Node banner landing on stdout ahead of the JSON (see #515)
/// is self-diagnosing in logs and the UI instead of an opaque "not valid JSON".
struct CLIDecodeFailure: Error, CustomStringConvertible {
let underlying: Error
let stdoutByteCount: Int
let stdoutSnippet: String
let stderr: String
var description: String {
var parts = [
"decode failed: \(underlying)",
"stdout (\(stdoutByteCount) bytes): \(stdoutSnippet.isEmpty ? "<empty>" : stdoutSnippet)",
]
if !stderr.isEmpty { parts.append("stderr: \(stderr)") }
return parts.joined(separator: " | ")
}
}
/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
/// commands through `/bin/zsh -c` anymore.
struct DataClient {
static func fetch(period: Period,
day: String? = nil,
days: Set<String> = [],
provider: ProviderFilter,
includeOptimize: Bool,
scope: MenubarScope = .local,
claudeConfigSourceId: String? = nil) async throws -> MenubarPayload {
let subcommand = statusSubcommand(
period: period,
day: day,
days: days,
provider: provider,
includeOptimize: includeOptimize,
scope: scope,
claudeConfigSourceId: claudeConfigSourceId
)
let result = try await runCLI(subcommand: subcommand)
guard result.exitCode == 0 else {
throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr)
}
do {
return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout)
} catch {
let snippet = String(decoding: result.stdout.prefix(2048), as: UTF8.self)
throw DataClientError.decode(CLIDecodeFailure(
underlying: error,
stdoutByteCount: result.stdout.count,
stdoutSnippet: snippet,
stderr: result.stderr
))
}
}
static func statusSubcommand(period: Period,
day: String? = nil,
days: Set<String> = [],
provider: ProviderFilter,
includeOptimize: Bool,
scope: MenubarScope = .local,
claudeConfigSourceId: String? = nil) -> [String] {
let effectiveScope: MenubarScope = days.count > 1 ? .local : scope
let effectiveProvider: ProviderFilter = effectiveScope == .combined ? .all : provider
var subcommand = [
"status",
"--format", "menubar-json",
"--provider", effectiveProvider.cliArg,
]
if effectiveScope == .combined {
subcommand.append(contentsOf: ["--scope", effectiveScope.cliArg])
}
if effectiveScope == .local, let claudeConfigSourceId, !claudeConfigSourceId.isEmpty {
subcommand.append(contentsOf: ["--claude-config-source", claudeConfigSourceId])
}
if days.count > 1 {
subcommand.append(contentsOf: ["--days", days.sorted().joined(separator: ",")])
} else if let day {
subcommand.append(contentsOf: ["--day", day])
} else if let d = days.first {
subcommand.append(contentsOf: ["--day", d])
} else {
subcommand.append(contentsOf: ["--period", period.cliArg])
}
if !includeOptimize {
subcommand.append("--no-optimize")
}
return subcommand
}
struct ProcessResult {
let stdout: Data
let stderr: String
let exitCode: Int32
}
/// Caps concurrent CLI spawns so a wake-burst of refreshes can't fan out into
/// dozens of node processes at once.
private static let spawnLimiter = AsyncSemaphore(maxConcurrentSpawns)
private static func runCLI(subcommand: [String]) async throws -> ProcessResult {
await spawnLimiter.acquire()
defer { Task { await spawnLimiter.release() } }
let process = CodeburnCLI.makeProcess(subcommand: subcommand)
return try await runProcess(process,
timeoutSeconds: spawnTimeoutSeconds,
label: subcommand.joined(separator: " "))
}
/// Runs an already-configured process to completion, draining its output and
/// enforcing a hard timeout.
///
/// CRITICAL: nothing here may block a worker thread waiting for the process.
/// `process.waitUntilExit()` is a blocking syscall. An earlier fix moved it
/// onto a global(qos:.utility) queue with the timeout on that SAME queue but
/// under sustained load every utility worker ended up blocked in waitUntilExit,
/// so the timeout could never be scheduled to kill them and the menubar wedged
/// on "Loading" forever (confirmed via sample: threads parked in
/// waitUntilExit, timeout never firing). Instead we await
/// `process.terminationHandler`, which fires on a Foundation-managed queue and
/// blocks nothing, so the timeout always has a free thread to fire on.
static func runProcess(_ process: Process,
timeoutSeconds: UInt64,
label: String) async throws -> ProcessResult {
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
// Bridge the process exit to an async signal set up BEFORE run(), so the
// exit can never be missed and the wait never blocks a worker thread.
let exitSignal = ProcessExitSignal()
process.terminationHandler = { _ in exitSignal.fulfill() }
do {
try process.run()
} catch {
throw DataClientError.spawn(error.localizedDescription)
}
let timeoutTimer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
timeoutTimer.schedule(deadline: .now() + .seconds(Int(timeoutSeconds)))
timeoutTimer.setEventHandler {
if process.isRunning {
NSLog("CodeBurn: CLI subprocess timed out after %llus for %@ — terminating",
timeoutSeconds, label)
terminateWithEscalation(process)
}
}
timeoutTimer.resume()
defer { timeoutTimer.cancel() }
let outHandle = outPipe.fileHandleForReading
let errHandle = errPipe.fileHandleForReading
let (out, err) = await withTaskCancellationHandler {
async let stdoutData = drain(outHandle, limit: maxPayloadBytes)
async let stderrData = drain(errHandle, limit: maxStderrBytes)
return await (stdoutData, stderrData)
} onCancel: {
terminateWithEscalation(process)
}
try? outHandle.close()
try? errHandle.close()
// Wait for exit via terminationHandler, never by parking a worker thread
// in waitUntilExit (see the doc comment above for why that wedged).
await exitSignal.wait()
if out.count >= maxPayloadBytes {
throw DataClientError.outputTooLarge
}
let stderrString = String(data: err, encoding: .utf8) ?? ""
return ProcessResult(stdout: out, stderr: stderrString, exitCode: process.terminationStatus)
}
private static func terminateWithEscalation(_ process: Process) {
guard process.isRunning else { return }
process.terminate()
let pid = process.processIdentifier
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) {
if process.isRunning { kill(pid, SIGKILL) }
}
}
private static func drain(_ handle: FileHandle, limit: Int) async -> Data {
let fd = handle.fileDescriptor
let flags = Darwin.fcntl(fd, F_GETFL)
if flags >= 0 {
_ = Darwin.fcntl(fd, F_SETFL, flags | O_NONBLOCK)
} else {
NSLog("CodeBurn: fcntl F_GETFL failed on fd %d, drain may block", fd)
}
var buffer = Data()
var chunk = [UInt8](repeating: 0, count: 65_536)
while buffer.count < limit && !Task.isCancelled {
let toRead = min(chunk.count, limit - buffer.count)
let n = chunk.withUnsafeMutableBufferPointer { ptr in
Darwin.read(fd, ptr.baseAddress!, toRead)
}
if n > 0 {
buffer.append(contentsOf: chunk.prefix(n))
} else if n == 0 {
break
} else if errno == EAGAIN || errno == EWOULDBLOCK {
try? await Task.sleep(nanoseconds: 5_000_000)
} else if errno == EINTR {
continue
} else {
NSLog("CodeBurn: drain read() failed on fd %d: errno %d", fd, errno)
break
}
}
return buffer
}
}
/// One-shot async signal that bridges `Process.terminationHandler` (invoked on a
/// Foundation-internal queue) to an awaiting task without blocking a worker
/// thread. Safe against fulfill-before-wait.
final class ProcessExitSignal: @unchecked Sendable {
private let lock = NSLock()
private var fulfilled = false
private var continuation: CheckedContinuation<Void, Never>?
func fulfill() {
lock.lock()
if fulfilled { lock.unlock(); return }
fulfilled = true
let cont = continuation
continuation = nil
lock.unlock()
cont?.resume()
}
func wait() async {
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
lock.lock()
if fulfilled {
lock.unlock()
cont.resume()
} else {
continuation = cont
lock.unlock()
}
}
}
}
/// Minimal actor-based async semaphore. Caps concurrency without blocking a
/// thread (unlike DispatchSemaphore.wait()).
actor AsyncSemaphore {
private var available: Int
private var waiters: [CheckedContinuation<Void, Never>] = []
init(_ count: Int) { available = count }
func acquire() async {
if available > 0 {
available -= 1
return
}
await withCheckedContinuation { waiters.append($0) }
}
func release() {
if waiters.isEmpty {
available += 1
} else {
waiters.removeFirst().resume()
}
}
}
@@ -0,0 +1,468 @@
import Foundation
/// Shape of `codeburn status --format menubar-json --period <period>`.
/// `current` is scoped to the requested period; the whole payload reflects that slice.
struct MenubarPayload: Codable, Sendable {
let generated: String
let current: CurrentBlock
let optimize: OptimizeBlock
let history: HistoryBlock
let combined: CombinedUsage?
let claudeConfigs: ClaudeConfigSelector?
init(generated: String,
current: CurrentBlock,
optimize: OptimizeBlock,
history: HistoryBlock,
combined: CombinedUsage?,
claudeConfigs: ClaudeConfigSelector? = nil) {
self.generated = generated
self.current = current
self.optimize = optimize
self.history = history
self.combined = combined
self.claudeConfigs = claudeConfigs
}
enum CodingKeys: String, CodingKey {
case generated, current, optimize, history, combined, claudeConfigs
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
generated = try c.decode(String.self, forKey: .generated)
current = try c.decode(CurrentBlock.self, forKey: .current)
optimize = try c.decode(OptimizeBlock.self, forKey: .optimize)
history = try c.decode(HistoryBlock.self, forKey: .history)
combined = try c.decodeIfPresent(CombinedUsage.self, forKey: .combined)
claudeConfigs = try c.decodeIfPresent(ClaudeConfigSelector.self, forKey: .claudeConfigs)
}
}
struct ClaudeConfigSelector: Codable, Sendable {
let selectedId: String?
let options: [ClaudeConfigOption]
}
struct ClaudeConfigOption: Codable, Identifiable, Hashable, Sendable {
let id: String
let label: String
let path: String
}
struct CombinedUsage: Codable, Sendable {
let perDevice: [CombinedDeviceUsage]
let combined: CombinedUsageTotals
}
struct CombinedDeviceUsage: Codable, Sendable {
let id: String
let name: String
let local: Bool
let error: String?
let cost: Double
let calls: Int
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let cacheCreateTokens: Int
let cacheReadTokens: Int
let totalTokens: Int
}
struct CombinedUsageTotals: Codable, Sendable {
let cost: Double
let calls: Int
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let cacheCreateTokens: Int
let cacheReadTokens: Int
let totalTokens: Int
let deviceCount: Int
let reachableCount: Int
}
struct HistoryBlock: Codable, Sendable {
let daily: [DailyHistoryEntry]
}
struct DailyModelBreakdown: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
var totalTokens: Int { inputTokens + outputTokens }
enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD, calls, inputTokens, outputTokens
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
calls = try c.decode(Int.self, forKey: .calls)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
}
}
struct DailyHistoryEntry: Codable, Sendable {
let date: String
let cost: Double
let savingsUSD: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
let cacheReadTokens: Int
let cacheWriteTokens: Int
let topModels: [DailyModelBreakdown]
/// Pricing-ratio prior: input + 5x output + cache_creation + 0.1x cache_read.
/// Matches Anthropic's published per-token pricing on Sonnet/Opus closely enough to be a useful proxy.
var effectiveTokens: Double {
Double(inputTokens) + 5.0 * Double(outputTokens) + Double(cacheWriteTokens) + 0.1 * Double(cacheReadTokens)
}
}
extension DailyHistoryEntry {
/// Required for legacy payloads (no topModels emitted yet).
enum CodingKeys: String, CodingKey {
case date, cost, savingsUSD, calls, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, topModels
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
date = try c.decode(String.self, forKey: .date)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
calls = try c.decode(Int.self, forKey: .calls)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
cacheReadTokens = try c.decode(Int.self, forKey: .cacheReadTokens)
cacheWriteTokens = try c.decode(Int.self, forKey: .cacheWriteTokens)
topModels = try c.decodeIfPresent([DailyModelBreakdown].self, forKey: .topModels) ?? []
}
}
struct RetryTaxModelEntry: Codable, Sendable {
let name: String
let taxUSD: Double
let retries: Int
let retriesPerEdit: Double?
}
struct RetryTax: Codable, Sendable {
let totalUSD: Double
let retries: Int
let editTurns: Int
let byModel: [RetryTaxModelEntry]
}
struct RoutingWasteModelEntry: Codable, Sendable {
let name: String
let costPerEdit: Double
let editTurns: Int
let actualUSD: Double
let counterfactualUSD: Double
let savingsUSD: Double
}
struct RoutingWaste: Codable, Sendable {
let totalSavingsUSD: Double
let baselineModel: String
let baselineCostPerEdit: Double
let byModel: [RoutingWasteModelEntry]
}
struct CurrentBlock: Codable, Sendable {
let label: String
let cost: Double
let calls: Int
let sessions: Int
let oneShotRate: Double?
let inputTokens: Int
let outputTokens: Int
let cacheHitPercent: Double
/// Codex credits consumed in the period (nil on payloads from older builds).
let codexCredits: Double?
let topActivities: [ActivityEntry]
let topModels: [ModelEntry]
let localModelSavings: LocalModelSavings
let providers: [String: Double]
let topProjects: [ProjectEntry]
let modelEfficiency: [ModelEfficiencyEntry]
let topSessions: [TopSessionEntry]
let retryTax: RetryTax
let routingWaste: RoutingWaste
let tools: [ToolEntry]
let skills: [SkillEntry]
let subagents: [SubagentEntry]
let mcpServers: [McpServerEntry]
}
extension CurrentBlock {
enum CodingKeys: String, CodingKey {
case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens,
cacheHitPercent, codexCredits, topActivities, topModels, localModelSavings, providers, topProjects,
modelEfficiency, topSessions, retryTax, routingWaste,
tools, skills, subagents, mcpServers
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
label = try c.decode(String.self, forKey: .label)
cost = try c.decode(Double.self, forKey: .cost)
calls = try c.decode(Int.self, forKey: .calls)
sessions = try c.decode(Int.self, forKey: .sessions)
oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
cacheHitPercent = try c.decodeIfPresent(Double.self, forKey: .cacheHitPercent) ?? 0
codexCredits = try c.decodeIfPresent(Double.self, forKey: .codexCredits)
topActivities = try c.decodeIfPresent([ActivityEntry].self, forKey: .topActivities) ?? []
topModels = try c.decodeIfPresent([ModelEntry].self, forKey: .topModels) ?? []
localModelSavings = try c.decodeIfPresent(LocalModelSavings.self, forKey: .localModelSavings) ?? LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: [])
providers = try c.decodeIfPresent([String: Double].self, forKey: .providers) ?? [:]
topProjects = try c.decodeIfPresent([ProjectEntry].self, forKey: .topProjects) ?? []
modelEfficiency = try c.decodeIfPresent([ModelEfficiencyEntry].self, forKey: .modelEfficiency) ?? []
topSessions = try c.decodeIfPresent([TopSessionEntry].self, forKey: .topSessions) ?? []
retryTax = try c.decodeIfPresent(RetryTax.self, forKey: .retryTax) ?? RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: [])
routingWaste = try c.decodeIfPresent(RoutingWaste.self, forKey: .routingWaste) ?? RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: [])
tools = try c.decodeIfPresent([ToolEntry].self, forKey: .tools) ?? []
skills = try c.decodeIfPresent([SkillEntry].self, forKey: .skills) ?? []
subagents = try c.decodeIfPresent([SubagentEntry].self, forKey: .subagents) ?? []
mcpServers = try c.decodeIfPresent([McpServerEntry].self, forKey: .mcpServers) ?? []
}
}
struct LocalModelSavingsByModel: Codable, Sendable {
let name: String
let calls: Int
let actualUSD: Double
let savingsUSD: Double
let baselineModel: String
let inputTokens: Int
let outputTokens: Int
}
struct LocalModelSavingsByProvider: Codable, Sendable {
let name: String
let calls: Int
let savingsUSD: Double
}
struct LocalModelSavings: Codable, Sendable {
let totalUSD: Double
let calls: Int
let byModel: [LocalModelSavingsByModel]
let byProvider: [LocalModelSavingsByProvider]
}
struct ActivityEntry: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
let turns: Int
let oneShotRate: Double?
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
turns = try c.decode(Int.self, forKey: .turns)
oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate)
}
private enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD, turns, oneShotRate
}
}
struct ModelEntry: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
let savingsBaselineModel: String
let calls: Int
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
savingsBaselineModel = try c.decodeIfPresent(String.self, forKey: .savingsBaselineModel) ?? ""
calls = try c.decode(Int.self, forKey: .calls)
}
private enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD, savingsBaselineModel, calls
}
}
struct SessionModelEntry: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
}
private enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD
}
}
struct SessionDetailEntry: Codable, Sendable {
let cost: Double
let savingsUSD: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
let date: String
let models: [SessionModelEntry]
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
calls = try c.decode(Int.self, forKey: .calls)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
date = try c.decode(String.self, forKey: .date)
models = try c.decodeIfPresent([SessionModelEntry].self, forKey: .models) ?? []
}
private enum CodingKeys: String, CodingKey {
case cost, savingsUSD, calls, inputTokens, outputTokens, date, models
}
}
struct ProjectEntry: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
let sessions: Int
let avgCostPerSession: Double
let sessionDetails: [SessionDetailEntry]
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
sessions = try c.decode(Int.self, forKey: .sessions)
avgCostPerSession = try c.decode(Double.self, forKey: .avgCostPerSession)
sessionDetails = try c.decodeIfPresent([SessionDetailEntry].self, forKey: .sessionDetails) ?? []
}
private enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD, sessions, avgCostPerSession, sessionDetails
}
}
struct ModelEfficiencyEntry: Codable, Sendable {
let name: String
let costPerEdit: Double?
let oneShotRate: Double?
}
struct TopSessionEntry: Codable, Sendable {
let project: String
let cost: Double
let savingsUSD: Double
let calls: Int
let date: String
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
project = try c.decode(String.self, forKey: .project)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
calls = try c.decode(Int.self, forKey: .calls)
date = try c.decode(String.self, forKey: .date)
}
private enum CodingKeys: String, CodingKey {
case project, cost, savingsUSD, calls, date
}
}
struct ToolEntry: Codable, Sendable {
let name: String
let calls: Int
}
struct SkillEntry: Codable, Sendable {
let name: String
let turns: Int
let cost: Double
}
struct SubagentEntry: Codable, Sendable {
let name: String
let calls: Int
let cost: Double
}
struct McpServerEntry: Codable, Sendable {
let name: String
let calls: Int
}
struct OptimizeBlock: Codable, Sendable {
let findingCount: Int
let savingsUSD: Double
let topFindings: [FindingEntry]
}
struct FindingEntry: Codable, Sendable {
let title: String
let impact: String
let savingsUSD: Double
}
// MARK: - Empty fallback
extension MenubarPayload {
/// Strictly-empty payload. Used as the fallback before real data arrives, so no
/// plausible-looking fake numbers leak into the UI.
static let empty = MenubarPayload(
generated: "",
current: CurrentBlock(
label: "",
cost: 0,
calls: 0,
sessions: 0,
oneShotRate: nil,
inputTokens: 0,
outputTokens: 0,
cacheHitPercent: 0,
codexCredits: nil,
topActivities: [],
topModels: [],
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
providers: [:],
topProjects: [],
modelEfficiency: [],
topSessions: [],
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []),
tools: [],
skills: [],
subagents: [],
mcpServers: []
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: []),
combined: nil,
claudeConfigs: nil
)
}
@@ -0,0 +1,43 @@
import Foundation
/// On-disk badge backstop. The app writes `menubar-status.json` on each successful
/// refresh and reads it back as a badge fallback after a restart, before the live
/// loop has produced a payload. Shares the `MenubarPayload` model with the live
/// path no separate data model.
struct MenubarStatusCache {
let statusPath: String
/// Default location under `~/.cache/codeburn/`.
static func standard() -> MenubarStatusCache {
let home = FileManager.default.homeDirectoryForCurrentUser.path
return MenubarStatusCache(statusPath: "\(home)/.cache/codeburn/menubar-status.json")
}
struct BadgeRead {
let payload: MenubarPayload
let ageSeconds: TimeInterval
}
/// Decodes the status file and returns it with its age (from the file's
/// mtime). Returns nil when the file is missing, corrupt, unreadable, or
/// older than `maxAgeSeconds` every failure mode silently falls back to
/// the in-memory payload, never crashes.
func readBadgePayload(maxAgeSeconds: TimeInterval) -> BadgeRead? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: statusPath),
let mtime = attrs[.modificationDate] as? Date else {
return nil
}
let age = Date().timeIntervalSince(mtime)
guard age >= 0, age <= maxAgeSeconds else { return nil }
guard let data = try? SafeFile.read(from: statusPath),
let payload = try? JSONDecoder().decode(MenubarPayload.self, from: data) else {
return nil
}
return BadgeRead(payload: payload, ageSeconds: age)
}
func writeStatus(_ payload: MenubarPayload) throws {
let data = try JSONEncoder().encode(payload)
try SafeFile.write(data, to: statusPath, mode: 0o600)
}
}
@@ -0,0 +1,75 @@
import Foundation
/// Per-provider live-quota snapshot consumed by the AgentTab progress bar
/// and the hover-detail popover. Today only Claude has a real quota source
/// (Anthropic /api/oauth/usage); future providers (Cursor, Copilot, etc.)
/// will plug in by producing the same struct from their own auth path.
struct QuotaSummary: Equatable {
enum Connection: Equatable {
case connected
case disconnected // no credentials present
case loading
case stale // had data once, current fetch is in flight
case transientFailure // backing off; show last-known data dimmed
case terminalFailure(reason: String?) // user must reconnect
}
let providerFilter: ProviderFilter
let connection: Connection
let primary: Window? // weekly utilization, the headline bar
let details: [Window] // 5h, weekly, opus, sonnet full hover card
/// Display label for the user's plan (e.g. "Max 20x", "Pro Lite"). Shown
/// in the top-right corner of the hover detail popover so users can
/// confirm at a glance which subscription is feeding the bar.
let planLabel: String?
/// Optional footer rows that the popover renders below the window list.
/// Used today only by Codex to surface the on-account credits balance,
/// but kept generic so future providers can add provider-specific facts
/// (e.g. "Anthropic incident in progress", "Cursor team seat").
let footerLines: [String]
struct Window: Equatable {
let label: String
let percent: Double // 0..1
let resetsAt: Date?
}
/// Color band thresholds for the inline chip bar and aggregate menubar
/// flame tint. Four tiers so the icon can step from "you're approaching
/// your limit" (yellow) through "you're about to hit the wall" (orange)
/// to "you're over" (red) matches what the user expects from a warning
/// indicator in the menu bar.
static func severity(for percent: Double) -> Severity {
if percent >= 1.0 { return .danger }
if percent >= 0.9 { return .critical }
if percent >= 0.7 { return .warning }
return .normal
}
enum Severity {
case normal // <70%
case warning // 70-90%
case critical // 90-100%
case danger // >=100%
}
}
extension QuotaSummary.Window {
/// Human-readable countdown like "2h 11m" or "3d 14h" or "now".
var resetsInLabel: String {
guard let resetsAt else { return "" }
let seconds = max(0, resetsAt.timeIntervalSinceNow)
if seconds < 60 { return "now" }
let minutes = Int(seconds / 60)
let hours = minutes / 60
let days = hours / 24
if days > 0 { return "\(days)d \(hours % 24)h" }
if hours > 0 { return "\(hours)h \(minutes % 60)m" }
return "\(minutes)m"
}
var percentLabel: String {
let pct = Int((percent * 100).rounded())
return "\(pct)%"
}
}
@@ -0,0 +1,42 @@
import Foundation
/// User-configurable cadence for /api/oauth/usage polling. Mirrors CodexBar's
/// "manual / 1m / 2m / 5m / 15m" preset set so users on tight rate-limit
/// budgets can dial it down and power users can dial it up. Stored as the raw
/// number of seconds in UserDefaults; `manual = 0` means "never auto-refresh".
enum SubscriptionRefreshCadence: Int, CaseIterable, Identifiable {
case manual = 0
case oneMinute = 60
case twoMinutes = 120
case fiveMinutes = 300
case fifteenMinutes = 900
var id: Int { rawValue }
var label: String {
switch self {
case .manual: return "Manual"
case .oneMinute: return "1 minute"
case .twoMinutes: return "2 minutes"
case .fiveMinutes: return "5 minutes"
case .fifteenMinutes: return "15 minutes"
}
}
static let defaultsKey = "codeburn.claude.refreshCadenceSeconds"
static let `default`: SubscriptionRefreshCadence = .twoMinutes
static var current: SubscriptionRefreshCadence {
get {
// UserDefaults.integer returns 0 when the key is missing that
// happens to alias `manual`, which is wrong for a fresh install.
// Probe with object(forKey:) so we can distinguish "never set"
// from "set to manual" and seed the default on first run.
if UserDefaults.standard.object(forKey: defaultsKey) == nil {
return .default
}
return SubscriptionRefreshCadence(rawValue: UserDefaults.standard.integer(forKey: defaultsKey)) ?? .default
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: defaultsKey) }
}
}
@@ -0,0 +1,109 @@
import Foundation
/// Persisted snapshot of a single utilization reading. We capture one per window every time
/// SubscriptionClient.fetch() succeeds so we can answer "what did the prior 7-day cycle finish at?"
/// when the current window has no usable data yet (just reset).
struct SubscriptionSnapshot: Codable, Sendable {
let windowKey: String // "five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"
let percent: Double // 0..100
let resetsAt: Date // resets_at active at capture (identifies which window cycle this belongs to)
let capturedAt: Date // when the snapshot was recorded
let effectiveTokens: Double? // tokens consumed in window at capture (nil if not computed)
}
private let snapshotFilename = "subscription-snapshots.json"
private let pruneOlderThanSeconds: TimeInterval = 30 * 24 * 3600
private func snapshotsCacheDir() -> String {
return ProcessInfo.processInfo.environment["CODEBURN_CACHE_DIR"]
?? (NSHomeDirectory() as NSString).appendingPathComponent(".cache/codeburn")
}
private func snapshotsPath() -> String {
return (snapshotsCacheDir() as NSString).appendingPathComponent(snapshotFilename)
}
private actor SnapshotLock {
static let shared = SnapshotLock()
func run<T>(_ fn: () throws -> T) rethrows -> T { try fn() }
}
enum SubscriptionSnapshotStore {
/// Append a snapshot. Auto-prunes entries older than 30 days. Idempotent: if a snapshot
/// with the same windowKey + resetsAt already exists, only update percent if new is higher
/// (so "final" reading near reset is preserved).
static func record(_ snapshot: SubscriptionSnapshot) async {
await SnapshotLock.shared.run {
do {
var all = loadAll()
let key = "\(snapshot.windowKey)|\(snapshot.resetsAt.timeIntervalSince1970)"
if let idx = all.firstIndex(where: { "\($0.windowKey)|\($0.resetsAt.timeIntervalSince1970)" == key }) {
if snapshot.percent > all[idx].percent {
all[idx] = snapshot
}
} else {
all.append(snapshot)
}
let cutoff = Date().addingTimeInterval(-pruneOlderThanSeconds)
all = all.filter { $0.capturedAt >= cutoff }
try save(all)
} catch {
NSLog("CodeBurn: snapshot record failed: \(error)")
}
}
}
/// Returns the final percent of the immediately-prior cycle for this window, or nil if no
/// prior data is available. Logic: among snapshots whose resetsAt < currentResetsAt, pick
/// the group with the largest resetsAt (most recent prior cycle), then return the max
/// percent in that group (the closest-to-final reading we have).
static func previousWindowFinal(windowKey: String, currentResetsAt: Date) async -> Double? {
await SnapshotLock.shared.run {
let all = loadAll()
let priors = all.filter { $0.windowKey == windowKey && $0.resetsAt < currentResetsAt }
guard let mostRecentPriorReset = priors.map({ $0.resetsAt }).max() else { return nil }
let priorWindow = priors.filter { $0.resetsAt == mostRecentPriorReset }
return priorWindow.map(\.percent).max()
}
}
/// Return all snapshots for a given window key, useful for capacity estimation.
static func snapshots(for windowKey: String) async -> [SubscriptionSnapshot] {
await SnapshotLock.shared.run {
loadAll().filter { $0.windowKey == windowKey }
}
}
/// Test seam: clear all snapshots.
static func resetForTesting() async {
await clearAll()
}
/// Wipe all snapshots from disk. Called when the user disconnects so the
/// "Based on last cycle" projections do not contaminate a reconnect under
/// a different account or tier.
static func clearAll() async {
await SnapshotLock.shared.run {
try? FileManager.default.removeItem(atPath: snapshotsPath())
}
}
// MARK: - Internals
private static func loadAll() -> [SubscriptionSnapshot] {
let path = snapshotsPath()
guard FileManager.default.fileExists(atPath: path) else { return [] }
guard let data = try? SafeFile.read(from: path) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([SubscriptionSnapshot].self, from: data)) ?? []
}
private static func save(_ snapshots: [SubscriptionSnapshot]) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(snapshots)
// SafeFile.write refuses symlinked targets and does the tmp+rename atomic dance.
try SafeFile.write(data, to: snapshotsPath(), mode: 0o600)
}
}
@@ -0,0 +1,56 @@
import Foundation
struct SubscriptionUsage: Sendable, Equatable {
enum Tier: String, Sendable, Equatable {
case pro
case max5x
case max20x
case team
case enterprise
case unknown
var displayName: String {
switch self {
case .pro: "Pro"
case .max5x: "Max 5x"
case .max20x: "Max 20x"
case .team: "Team"
case .enterprise: "Enterprise"
case .unknown: "Subscription"
}
}
}
/// A model-scoped weekly limit from the `limits` array (e.g. the Fable
/// bucket). The label is the API's `scope.model.display_name`, so new
/// model buckets show up without a client update.
struct ScopedWindow: Sendable, Equatable {
let label: String
let percent: Double
let resetsAt: Date?
}
let tier: Tier
let rawTier: String?
let fiveHourPercent: Double?
let fiveHourResetsAt: Date?
let sevenDayPercent: Double?
let sevenDayResetsAt: Date?
let sevenDayOpusPercent: Double?
let sevenDayOpusResetsAt: Date?
let sevenDaySonnetPercent: Double?
let sevenDaySonnetResetsAt: Date?
let scopedWeekly: [ScopedWindow]
let fetchedAt: Date
static func tier(from raw: String?) -> Tier {
guard let raw = raw?.lowercased() else { return .unknown }
if raw.contains("max_20x") || raw.contains("max20x") || raw.contains("max-20x") { return .max20x }
if raw.contains("max_5x") || raw.contains("max5x") || raw.contains("max-5x") { return .max5x }
if raw.contains("max") { return .max5x }
if raw.contains("pro") { return .pro }
if raw.contains("team") { return .team }
if raw.contains("enterprise") { return .enterprise }
return .unknown
}
}
@@ -0,0 +1,258 @@
import Foundation
import Observation
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 cachedCliVersionKey = "UpdateChecker.latestCliVersion"
private let updateTimeoutSeconds: UInt64 = 120
private let maxUpdateStderrBytes = 64 * 1024
// The installer that scans `mac-v*` releases for the menubar zip (instead of
// `/releases/latest`, which can resolve to a CLI release that carries no menubar
// asset) landed in CLI 0.9.9 (commit 909efcf). Older CLIs cannot perform a correct
// `menubar --force`, so we refuse to run them and ask the user to upgrade the CLI first.
private let minCliVersionForUpdate = "0.9.9"
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
final class UpdateChecker {
var latestVersion: String?
var latestCliVersion: String?
var installedCliVersion: String?
var isUpdating = false
var updateError: String?
var updateAvailable: Bool {
guard let latest = latestVersion else { return false }
let current = currentVersion
let normalizedLatest = AppVersion.normalize(latest)
let normalizedCurrent = AppVersion.normalize(current)
guard !normalizedCurrent.isEmpty && normalizedCurrent != "dev" else { return false }
return normalizedLatest.compare(normalizedCurrent, options: .numeric) == .orderedDescending
}
var cliUpdateAvailable: Bool {
guard let latest = latestCliVersion, let installed = installedCliVersion else { return false }
let normalizedLatest = AppVersion.normalize(latest)
let normalizedInstalled = AppVersion.normalize(installed)
guard !normalizedInstalled.isEmpty else { return false }
return normalizedLatest.compare(normalizedInstalled, options: .numeric) == .orderedDescending
}
/// True when the installed CLI predates the `menubar --force` fix and would fail to
/// install the new app. Distinct from `cliUpdateAvailable`: a CLI can be behind the
/// latest release yet still new enough (>= 0.9.9) to update the menubar correctly.
var cliTooOldForUpdate: Bool {
Self.isCliTooOld(installed: installedCliVersion)
}
var cliUpdateCommand: String {
let argv = CodeburnCLI.baseArgv()
let path = argv.first ?? ""
if path.contains("/homebrew/") { return "brew upgrade codeburn" }
return "npm update -g codeburn"
}
var currentVersion: String {
AppVersion.normalizedBundleShortVersion
}
func checkIfNeeded() async {
installedCliVersion = Self.queryInstalledCliVersion()
let lastCheck = UserDefaults.standard.double(forKey: lastCheckKey)
let now = Date().timeIntervalSince1970
if now - lastCheck < checkIntervalSeconds {
latestVersion = UserDefaults.standard.string(forKey: cachedVersionKey)
latestCliVersion = UserDefaults.standard.string(forKey: cachedCliVersionKey)
return
}
await check()
}
func check() async {
updateError = nil
installedCliVersion = Self.queryInstalledCliVersion()
guard let url = URL(string: releasesAPI) else { return }
var request = URLRequest(url: url)
request.timeoutInterval = 30
request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent")
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
do {
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 = resolved.asset.name
.replacingOccurrences(of: "CodeBurnMenubar-", with: "")
.replacingOccurrences(of: ".zip", with: "")
let cliVersion = Self.resolveLatestCliVersion(in: releases)
latestVersion = version
latestCliVersion = cliVersion
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastCheckKey)
UserDefaults.standard.set(version, forKey: cachedVersionKey)
if let cliVersion { UserDefaults.standard.set(cliVersion, forKey: cachedCliVersionKey) }
} catch {
updateError = "Update check failed: \(error.localizedDescription)"
NSLog("CodeBurn: update check failed: \(error)")
}
}
nonisolated static func resolveLatestCliVersion(in releases: [GitHubRelease]) -> String? {
for release in releases where release.tag_name.hasPrefix("v") && !release.tag_name.hasPrefix("mac-v") {
return AppVersion.normalize(release.tag_name)
}
return nil
}
nonisolated static func queryInstalledCliVersion() -> String? {
let process = CodeburnCLI.makeProcess(subcommand: ["--version"])
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return nil }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return output.isEmpty ? nil : output
} catch {
return nil
}
}
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
}
nonisolated static func isCliTooOld(installed: String?) -> Bool {
guard let installed else { return false }
let normalizedInstalled = AppVersion.normalize(installed)
guard !normalizedInstalled.isEmpty else { return false }
return AppVersion.normalize(minCliVersionForUpdate).compare(normalizedInstalled, options: .numeric) == .orderedDescending
}
func performUpdate() {
installedCliVersion = Self.queryInstalledCliVersion()
if cliTooOldForUpdate {
updateError = "Your codeburn CLI (\(AppVersion.display(installedCliVersion ?? ""))) is too old to update the menubar. Run “\(cliUpdateCommand)” first, then try again."
return
}
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
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
if proc.terminationStatus != 0 {
self.updateError = stderr.isEmpty ? "Update failed (exit \(proc.terminationStatus))" : stderr
NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)")
} else {
self.latestVersion = nil
}
}
}
do {
try process.run()
} catch {
isUpdating = false
updateError = error.localizedDescription
NSLog("CodeBurn: update spawn failed: \(error)")
}
}
nonisolated private static func sanitizeForDisplay(_ value: String) -> String {
var cleaned = value.replacingOccurrences(of: "\u{0000}", with: "")
let patterns: [(String, String)] = [
(#"sk-ant-[A-Za-z0-9_-]+"#, "sk-ant-***"),
(#"sk-[A-Za-z0-9_-]{16,}"#, "sk-***"),
(#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#, "eyJ***"),
(#"(?i)Bearer\s+\S+"#, "Bearer ***"),
]
for (pattern, replacement) in patterns {
cleaned = cleaned.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression)
}
if cleaned.count > 1_000 { cleaned = String(cleaned.prefix(1_000)) + "..." }
return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
enum UpdateCheckError: LocalizedError {
case http(Int)
case missingMenubarAsset
var errorDescription: String? {
switch self {
case let .http(status): "GitHub returned HTTP \(status)."
case .missingMenubarAsset: "No mac-v release with a menubar zip and checksum was found."
}
}
}
struct GitHubRelease: Decodable {
let tag_name: String
let assets: [GitHubAsset]
}
struct GitHubAsset: Decodable {
let name: String
let browser_download_url: String
}
@@ -0,0 +1,42 @@
import Foundation
/// User-configurable cadence for the usage payload refresh loop (#647).
/// `auto` keeps the adaptive default: 30s while active, backed off on battery,
/// in Low Power Mode, and while the displays sleep. `manual = 0` never
/// auto-spawns; usage refreshes only on popover open, Refresh Now, and first
/// launch. Stored as raw seconds in UserDefaults (auto = -1), mirroring
/// SubscriptionRefreshCadence.
enum UsageRefreshCadence: Int, CaseIterable, Identifiable {
case auto = -1
case manual = 0
case oneMinute = 60
case fiveMinutes = 300
case fifteenMinutes = 900
var id: Int { rawValue }
var label: String {
switch self {
case .auto: return "Auto (30s, less on battery)"
case .manual: return "Manual"
case .oneMinute: return "1 minute"
case .fiveMinutes: return "5 minutes"
case .fifteenMinutes: return "15 minutes"
}
}
static let defaultsKey = "CodeBurnMenubarRefreshSeconds"
static let `default`: UsageRefreshCadence = .auto
static var current: UsageRefreshCadence {
get {
// integer(forKey:) returns 0 for a missing key, which aliases
// `manual`; probe object(forKey:) to seed the default instead.
if UserDefaults.standard.object(forKey: defaultsKey) == nil {
return .default
}
return UsageRefreshCadence(rawValue: UserDefaults.standard.integer(forKey: defaultsKey)) ?? .default
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: defaultsKey) }
}
}
@@ -0,0 +1,39 @@
import Foundation
private let menubarScopeDefaultsKey = "CodeBurnMenubarScope"
enum MenubarScope: String, CaseIterable, Identifiable, Sendable {
case local = "Local"
case combined = "Combined"
var id: String { rawValue }
var cliArg: String {
switch self {
case .local: "local"
case .combined: "combined"
}
}
var menubarDefaultsValue: String {
switch self {
case .local: "local"
case .combined: "combined"
}
}
init(menubarDefaultsValue: String?) {
switch menubarDefaultsValue {
case "combined": self = .combined
default: self = .local
}
}
static func savedMenubarScope(defaults: UserDefaults = .standard) -> MenubarScope {
MenubarScope(menubarDefaultsValue: defaults.string(forKey: menubarScopeDefaultsKey))
}
func persistAsMenubarDefault(defaults: UserDefaults = .standard) {
defaults.set(menubarDefaultsValue, forKey: menubarScopeDefaultsKey)
}
}
@@ -0,0 +1,50 @@
import Foundation
import IOKit.ps
/// Decides how often the background refresh loop may spawn CLI fetches. The
/// 30s timer keeps firing (cheap); this throttles the expensive part - each
/// fetch is a full Node process at 100%+ CPU for seconds (#647). With the
/// popover closed nobody is looking at anything but the status figure, so on
/// battery or in Low Power Mode the spawn cadence backs off. Opening the
/// popover always refreshes immediately via refreshPayloadForPopoverOpen, so
/// the backoff never shows a user stale data they are actually looking at.
enum RefreshCadence {
static let activeSeconds: TimeInterval = 30
static let batteryIdleSeconds: TimeInterval = 150
static let lowPowerIdleSeconds: TimeInterval = 300
/// nil means "never auto-spawn" (manual mode): usage refreshes only on
/// popover open, Refresh Now, and first launch.
static func interval(
mode: UsageRefreshCadence,
popoverOpen: Bool,
onBattery: Bool,
lowPowerMode: Bool
) -> TimeInterval? {
switch mode {
case .manual:
return nil
case .auto:
if popoverOpen { return activeSeconds }
if lowPowerMode { return lowPowerIdleSeconds }
if onBattery { return batteryIdleSeconds }
return activeSeconds
case .oneMinute, .fiveMinutes, .fifteenMinutes:
// A fixed user-chosen cadence, except an open popover always gets
// the active cadence: the user is looking at the numbers.
return popoverOpen
? min(activeSeconds, TimeInterval(mode.rawValue))
: TimeInterval(mode.rawValue)
}
}
}
enum PowerSource {
static func isOnBattery() -> Bool {
// Copy function -> retained; Get function -> borrowed (unretained).
guard let snapshot = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
let type = IOPSGetProvidingPowerSourceType(snapshot)?.takeUnretainedValue() as String?
else { return false }
return type == kIOPMBatteryPowerKey
}
}
@@ -0,0 +1,114 @@
import Foundation
/// Single entry point for spawning the `codeburn` CLI. All callers route through here so the
/// binary argv is validated once and no code path ever passes user-influenced strings through
/// a shell (`/bin/zsh -c`, `open --args`, AppleScript). This closes the shell-injection attack
/// surface end-to-end.
enum CodeburnCLI {
/// Matches a plain file path / program name: alphanumerics, dot, underscore, slash, hyphen,
/// space. Deliberately excludes shell metacharacters (`$`, `;`, `&`, `|`, quotes, backticks,
/// newlines) so a malicious `CODEBURN_BIN="codeburn; rm -rf ~"` can't slip through.
private static let safeArgPattern = try! NSRegularExpression(pattern: "^[A-Za-z0-9 ._/\\-]+$")
/// 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 userNodePaths: [String] = {
let home = FileManager.default.homeDirectoryForCurrentUser.path
var paths: [String] = []
for dir in ["\(home)/.volta/bin", "\(home)/.npm-global/bin", "\(home)/.asdf/shims"] {
paths.append(dir)
}
let nvmDir = ProcessInfo.processInfo.environment["NVM_DIR"] ?? "\(home)/.nvm"
let versionsDir = "\(nvmDir)/versions/node"
if let entries = try? FileManager.default.contentsOfDirectory(atPath: versionsDir) {
for entry in entries.sorted().reversed() {
let bin = "\(versionsDir)/\(entry)/bin"
if FileManager.default.isExecutableFile(atPath: "\(bin)/codeburn") {
paths.append(bin)
break
}
}
}
return paths
}()
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] {
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
}
return installedArgv()
}
private static func installedArgv() -> [String] {
if let persisted = persistedCLIPath(), isSafe(persisted), FileManager.default.isExecutableFile(atPath: persisted) {
return [persisted]
}
for candidate in (additionalPathEntries + userNodePaths).map({ "\($0)/codeburn" }) {
if isSafe(candidate), 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`
/// so PATH lookup happens without involving a shell, and augments PATH with Homebrew
/// defaults. Caller sets stdout/stderr pipes and calls `run()`.
static func makeProcess(subcommand: [String]) -> Process {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
var environment = ProcessInfo.processInfo.environment
environment["PATH"] = augmentedPath(environment["PATH"] ?? "")
process.environment = environment
// `env --` treats everything following as argv, not VAR=val pairs -- guards against an
// argument accidentally resembling an env assignment.
process.arguments = ["--"] + baseArgv() + subcommand
// The menubar runs as an accessory app with no foreground window, and macOS
// background-throttles accessory apps and their children. Without this lift the
// codeburn subprocess parses 5-10x slower than the same command run from a
// user-interactive terminal, which starves the 30s refresh cadence on large corpora.
process.qualityOfService = .userInitiated
return process
}
static func isSafe(_ s: String) -> Bool {
let range = NSRange(s.startIndex..<s.endIndex, in: s)
return safeArgPattern.firstMatch(in: s, range: range) != nil
}
private static func augmentedPath(_ existing: String) -> String {
var parts = existing.split(separator: ":", omittingEmptySubsequences: true).map(String.init)
for extra in additionalPathEntries + userNodePaths where !parts.contains(extra) {
parts.append(extra)
}
return parts.joined(separator: ":")
}
}
@@ -0,0 +1,128 @@
import Foundation
/// Symlink-safe file I/O with atomic writes and optional cross-process flock.
///
/// Every cache file we touch (`~/Library/Caches/codeburn-mac/fx-rates.json`,
/// `~/.cache/codeburn/subscription-snapshots.json`, `~/.config/codeburn/config.json`) is a
/// legitimate target for a local-symlink attack: if an attacker plants a symlink from one of
/// those paths to, say, `~/.ssh/config`, a naive `Data.write(to:)` blindly follows the link and
/// clobbers the real file. `O_NOFOLLOW` on the write() refuses the operation instead.
enum SafeFile {
enum Error: Swift.Error {
case symlinkDetected(String)
case openFailed(String, Int32)
case writeFailed(String, Int32)
case renameFailed(String, Int32)
case readFailed(String, Int32)
case sizeLimitExceeded(String, Int)
}
/// Default max bytes when reading untrusted cache files. Prevents a malicious cache file
/// from exhausting memory in the Swift process.
static let defaultReadLimit = 8 * 1024 * 1024
/// Refuses to follow symlinks and writes atomically via a tmp file + rename. `mode` is the
/// final file permission (0o600 by default so cache files stay user-private).
static func write(_ data: Data, to path: String, mode: mode_t = 0o600) throws {
let parent = (path as NSString).deletingLastPathComponent
try FileManager.default.createDirectory(
atPath: parent,
withIntermediateDirectories: true,
attributes: [.posixPermissions: NSNumber(value: 0o700)]
)
// Reject if the existing file is a symlink. We use lstat so the link itself is
// inspected, not its target.
var linkInfo = stat()
if lstat(path, &linkInfo) == 0, (linkInfo.st_mode & S_IFMT) == S_IFLNK {
throw Error.symlinkDetected(path)
}
let tmpPath = parent + "/.codeburn-" + UUID().uuidString + ".tmp"
let flags: Int32 = O_CREAT | O_WRONLY | O_EXCL | O_NOFOLLOW
let fd = Darwin.open(tmpPath, flags, mode)
guard fd >= 0 else {
throw Error.openFailed(tmpPath, errno)
}
let writeResult: Int = data.withUnsafeBytes { buffer -> Int in
guard let base = buffer.baseAddress else { return 0 }
return Darwin.write(fd, base, buffer.count)
}
let writeErrno = errno
fsync(fd)
Darwin.close(fd)
guard writeResult == data.count else {
unlink(tmpPath)
throw Error.writeFailed(tmpPath, writeErrno)
}
if rename(tmpPath, path) != 0 {
let renameErrno = errno
unlink(tmpPath)
throw Error.renameFailed(path, renameErrno)
}
}
/// Refuses to read through a symlink. `maxBytes` bounds the read so a tampered cache file
/// can't balloon the process.
static func read(from path: String, maxBytes: Int = defaultReadLimit) throws -> Data {
var linkInfo = stat()
guard lstat(path, &linkInfo) == 0 else {
throw Error.readFailed(path, errno)
}
if (linkInfo.st_mode & S_IFMT) == S_IFLNK {
throw Error.symlinkDetected(path)
}
let fd = Darwin.open(path, O_RDONLY | O_NOFOLLOW)
guard fd >= 0 else {
throw Error.readFailed(path, errno)
}
defer { Darwin.close(fd) }
let size = Int(linkInfo.st_size)
if size > maxBytes {
throw Error.sizeLimitExceeded(path, size)
}
var data = Data(count: size)
let readBytes: Int = data.withUnsafeMutableBytes { buffer -> Int in
guard let base = buffer.baseAddress else { return 0 }
return Darwin.read(fd, base, buffer.count)
}
guard readBytes >= 0 else {
throw Error.readFailed(path, errno)
}
if readBytes < size {
data = data.prefix(readBytes)
}
return data
}
/// Runs `body` while holding an exclusive POSIX advisory lock on `path`. The lock file is
/// created if missing (with 0o600 permissions) and released on scope exit, so other
/// codeburn processes (the CLI running in a terminal, say) block on the same file instead
/// of racing on a shared config.
static func withExclusiveLock<T>(at path: String, body: () throws -> T) throws -> T {
let parent = (path as NSString).deletingLastPathComponent
try FileManager.default.createDirectory(
atPath: parent,
withIntermediateDirectories: true,
attributes: [.posixPermissions: NSNumber(value: 0o700)]
)
let fd = Darwin.open(path, O_CREAT | O_RDWR | O_NOFOLLOW, 0o600)
guard fd >= 0 else {
throw Error.openFailed(path, errno)
}
defer { Darwin.close(fd) }
guard flock(fd, LOCK_EX) == 0 else {
throw Error.openFailed(path, errno)
}
defer { _ = flock(fd, LOCK_UN) }
return try body()
}
}
@@ -0,0 +1,65 @@
import AppKit
import Foundation
/// Runs commands in the user's Terminal. Every string that reaches AppleScript `do script`
/// must be whitespace-joined argv where each token passes `CodeburnCLI.isSafe` (regex allowlist
/// that excludes shell metacharacters), OR a hardcoded literal defined here. The private
/// `runInTerminal` re-validates any non-literal input defensively so a future caller can't
/// bypass the invariant.
/// Falls back to a detached headless spawn on machines without Terminal.app (iTerm/Ghostty/Warp
/// users) so the subcommand still runs.
enum TerminalLauncher {
private static let terminalPaths = [
"/System/Applications/Utilities/Terminal.app",
"/Applications/Utilities/Terminal.app",
]
static func open(subcommand: [String]) {
let argv = CodeburnCLI.baseArgv() + subcommand
guard argv.allSatisfy(CodeburnCLI.isSafe) else {
NSLog("CodeBurn: refusing to open terminal with unsafe argv")
return
}
let command = argv.joined(separator: " ")
if terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) {
runInTerminal(command: command, preValidated: true)
return
}
let headless = CodeburnCLI.makeProcess(subcommand: subcommand)
try? headless.run()
}
/// Launches `claude login` in Terminal.app so the user can complete the OAuth flow
/// without leaving CodeBurn. The command is a hardcoded literal -- no user input is
/// interpolated, so there's no injection surface.
static func openClaudeLogin() -> Bool {
guard terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) else {
NSLog("CodeBurn: Terminal.app not present; user must run `claude login` manually")
return false
}
runInTerminal(command: "claude login", preValidated: true)
return true
}
private static func runInTerminal(command: String, preValidated: Bool) {
if !preValidated {
let tokens = command.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
guard tokens.allSatisfy(CodeburnCLI.isSafe) else {
NSLog("CodeBurn: refusing to run unvalidated command in Terminal")
return
}
}
let script = """
tell application "Terminal"
activate
do script "\(command)"
end tell
"""
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", script]
try? process.run()
}
}
@@ -0,0 +1,35 @@
import SwiftUI
/// Design tokens. Accent colors are driven by ThemeState so the user can switch palettes.
@MainActor
enum Theme {
static let brandEmber = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
static var brandAccent: Color { ThemeState.shared.preset.base }
static var brandAccentLight: Color { ThemeState.shared.preset.light }
static var brandAccentDeep: Color { ThemeState.shared.preset.deep }
static var brandAccentGlow: Color { ThemeState.shared.preset.glow }
static let warmSurface = Color(red: 0xFA/255.0, green: 0xF7/255.0, blue: 0xF3/255.0)
static let warmSurfaceDark = Color(red: 0x1C/255.0, green: 0x18/255.0, blue: 0x16/255.0)
static let categoricalClaude = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
static let categoricalCursor = Color(red: 0x3F/255.0, green: 0x6B/255.0, blue: 0x8C/255.0)
static let categoricalCodex = Color(red: 0x4A/255.0, green: 0x7D/255.0, blue: 0x5C/255.0)
static let oneShotGood = Color(red: 0x30/255.0, green: 0xD1/255.0, blue: 0x58/255.0)
static let oneShotMid = Color(red: 0xFF/255.0, green: 0x9F/255.0, blue: 0x0A/255.0)
static let oneShotLow = Color(red: 0xFF/255.0, green: 0x45/255.0, blue: 0x3A/255.0)
// Semantic colors -- tuned to sit alongside the terracotta accent without clashing.
static let semanticDanger = Color(red: 0xC8/255.0, green: 0x3F/255.0, blue: 0x2C/255.0) // brick-red, terracotta-leaning
static let semanticWarning = Color(red: 0xD9/255.0, green: 0x8F/255.0, blue: 0x29/255.0) // amber, warmer than vanilla
static let semanticSuccess = Color(red: 0x4E/255.0, green: 0xA8/255.0, blue: 0x65/255.0) // muted green that holds against terracotta
}
extension Font {
/// SF Mono for currency values -- developer-tool identity.
static func codeMono(size: CGFloat, weight: Font.Weight = .regular) -> Font {
.system(size: size, weight: weight, design: .monospaced)
}
}
@@ -0,0 +1,88 @@
import SwiftUI
import Observation
enum AccentPreset: String, CaseIterable, Identifiable {
case ember = "Ember"
case blue = "Blue"
case purple = "Purple"
case pink = "Pink"
case red = "Red"
case orange = "Orange"
case yellow = "Yellow"
case green = "Green"
case graphite = "Graphite"
var id: String { rawValue }
/// Apple macOS dark-mode system accent colors (NSColor.system*).
var base: Color {
switch self {
case .ember: Color(red: 0xC9/255, green: 0x52/255, blue: 0x1D/255)
case .blue: Color(red: 0x0A/255, green: 0x84/255, blue: 0xFF/255)
case .purple: Color(red: 0xBF/255, green: 0x5A/255, blue: 0xF2/255)
case .pink: Color(red: 0xFF/255, green: 0x37/255, blue: 0x5F/255)
case .red: Color(red: 0xFF/255, green: 0x45/255, blue: 0x3A/255)
case .orange: Color(red: 0xFF/255, green: 0x9F/255, blue: 0x0A/255)
case .yellow: Color(red: 0xFF/255, green: 0xD6/255, blue: 0x0A/255)
case .green: Color(red: 0x30/255, green: 0xD1/255, blue: 0x58/255)
case .graphite: Color(red: 0x98/255, green: 0x98/255, blue: 0x9D/255)
}
}
var light: Color {
switch self {
case .ember: Color(red: 0xE8/255, green: 0x77/255, blue: 0x4A/255)
case .blue: Color(red: 0x40/255, green: 0x9C/255, blue: 0xFF/255)
case .purple: Color(red: 0xDA/255, green: 0x8F/255, blue: 0xF7/255)
case .pink: Color(red: 0xFF/255, green: 0x6E/255, blue: 0x8C/255)
case .red: Color(red: 0xFF/255, green: 0x6E/255, blue: 0x63/255)
case .orange: Color(red: 0xFF/255, green: 0xBD/255, blue: 0x4A/255)
case .yellow: Color(red: 0xFF/255, green: 0xE0/255, blue: 0x4A/255)
case .green: Color(red: 0x5A/255, green: 0xE0/255, blue: 0x78/255)
case .graphite: Color(red: 0xAE/255, green: 0xAE/255, blue: 0xB2/255)
}
}
var deep: Color {
switch self {
case .ember: Color(red: 0x8B/255, green: 0x3E/255, blue: 0x13/255)
case .blue: Color(red: 0x06/255, green: 0x52/255, blue: 0xB3/255)
case .purple: Color(red: 0x7C/255, green: 0x38/255, blue: 0xA8/255)
case .pink: Color(red: 0xB3/255, green: 0x26/255, blue: 0x42/255)
case .red: Color(red: 0xB3/255, green: 0x30/255, blue: 0x28/255)
case .orange: Color(red: 0xB3/255, green: 0x6F/255, blue: 0x06/255)
case .yellow: Color(red: 0xB3/255, green: 0x96/255, blue: 0x06/255)
case .green: Color(red: 0x20/255, green: 0x92/255, blue: 0x3D/255)
case .graphite: Color(red: 0x5E/255, green: 0x5E/255, blue: 0x62/255)
}
}
var glow: Color {
switch self {
case .ember: Color(red: 0xF0/255, green: 0xA0/255, blue: 0x70/255)
case .blue: Color(red: 0x80/255, green: 0xC0/255, blue: 0xFF/255)
case .purple: Color(red: 0xE0/255, green: 0xB8/255, blue: 0xFA/255)
case .pink: Color(red: 0xFF/255, green: 0x99/255, blue: 0xB0/255)
case .red: Color(red: 0xFF/255, green: 0x99/255, blue: 0x90/255)
case .orange: Color(red: 0xFF/255, green: 0xD0/255, blue: 0x80/255)
case .yellow: Color(red: 0xFF/255, green: 0xEA/255, blue: 0x80/255)
case .green: Color(red: 0x80/255, green: 0xF0/255, blue: 0x98/255)
case .graphite: Color(red: 0xC8/255, green: 0xC8/255, blue: 0xCC/255)
}
}
}
@MainActor
@Observable
final class ThemeState {
static let shared = ThemeState()
var preset: AccentPreset {
didSet { UserDefaults.standard.set(preset.rawValue, forKey: "CodeBurnAccentPreset") }
}
private init() {
let saved = UserDefaults.standard.string(forKey: "CodeBurnAccentPreset") ?? ""
self.preset = AccentPreset(rawValue: saved) ?? .ember
}
}
@@ -0,0 +1,87 @@
import SwiftUI
struct ActivitySection: View {
@Environment(AppStore.self) private var store
@State private var isExpanded: Bool = true
var body: some View {
CollapsibleSection(
caption: "Activity",
isExpanded: $isExpanded,
trailing: {
HStack(spacing: 8) {
Text("Cost").frame(minWidth: 54, alignment: .trailing)
Text("Turns").frame(minWidth: 52, alignment: .trailing)
Text("1-shot").frame(minWidth: 44, alignment: .trailing)
}
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
.tracking(-0.05)
}
) {
VStack(alignment: .leading, spacing: 7) {
let maxCost = max(store.payload.current.topActivities.map(\.cost).max() ?? 1, 0.01)
ForEach(store.payload.current.topActivities, id: \.name) { activity in
ActivityRow(activity: activity, maxCost: maxCost)
}
}
}
}
}
struct ActivityRow: View {
let activity: ActivityEntry
let maxCost: Double
var body: some View {
HStack(spacing: 8) {
FixedBar(fraction: activity.cost / maxCost)
.frame(width: 56, height: 6)
Text(activity.name)
.font(.system(size: 12.5, weight: .medium))
.frame(maxWidth: .infinity, alignment: .leading)
Text(activity.cost.asCompactCurrency())
.font(.codeMono(size: 12, weight: .medium))
.tracking(-0.2)
.frame(minWidth: 54, alignment: .trailing)
Text("\(activity.turns)")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 52, alignment: .trailing)
Text(oneShotText)
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 44, alignment: .trailing)
}
.padding(.horizontal, 2)
.padding(.vertical, 1)
}
private var oneShotText: String {
guard let rate = activity.oneShotRate else { return "" }
return "\(Int(rate * 100))%"
}
}
/// Fixed-width horizontal bar that shows a fill fraction.
struct FixedBar: View {
let fraction: Double
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(.secondary.opacity(0.15))
RoundedRectangle(cornerRadius: 2)
.fill(Theme.brandAccent)
.frame(width: max(0, min(geo.size.width, geo.size.width * CGFloat(fraction))))
}
}
}
}
@@ -0,0 +1,511 @@
import AppKit
import SwiftUI
/// Shared state read by the NSEvent local monitor closure. The closure
/// snapshots its captured environment at install time, so SwiftUI @State
/// can't be used directly a reference-type holder keeps the latest hover
/// status visible to the monitor across SwiftUI updates.
@MainActor
final class AgentTabStripScrollState {
static let shared = AgentTabStripScrollState()
var isStripHovered: Bool = false
}
struct AgentTabStrip: View {
@Environment(AppStore.self) private var store
@State private var stripViewportWidth: CGFloat = 0
@State private var stripContentWidth: CGFloat = 0
@State private var scrollWheelMonitor: Any?
var body: some View {
GeometryReader { viewportGeo in
ScrollViewReader { proxy in
HStack(spacing: 4) {
if isOverflowing {
Button {
selectAdjacentProvider(direction: -1, proxy: proxy)
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 10, weight: .semibold))
.frame(width: 18, height: 18)
}
.buttonStyle(.plain)
.foregroundStyle(canMoveBackward ? Color.primary : Color.secondary.opacity(0.35))
.disabled(!canMoveBackward)
.help("Show previous providers")
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 5) {
ForEach(visibleFilters) { filter in
AgentTab(
filter: filter,
cost: cost(for: filter),
isActive: store.selectedProvider == filter,
quota: store.quotaSummary(for: filter)
) {
store.switchTo(provider: filter)
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(filter.id, anchor: .center)
}
}
.id(filter.id)
}
}
.background(
GeometryReader { contentGeo in
Color.clear
.onAppear {
stripContentWidth = contentGeo.size.width
}
.onChange(of: contentGeo.size.width) { _, newWidth in
stripContentWidth = newWidth
}
}
)
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 4)
.onHover { hovering in
AgentTabStripScrollState.shared.isStripHovered = hovering
}
if isOverflowing {
Button {
selectAdjacentProvider(direction: 1, proxy: proxy)
} label: {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.frame(width: 18, height: 18)
}
.buttonStyle(.plain)
.foregroundStyle(canMoveForward ? Color.primary : Color.secondary.opacity(0.35))
.disabled(!canMoveForward)
.help("Show next providers")
}
}
.onAppear {
stripViewportWidth = viewportGeo.size.width
installScrollWheelMonitorIfNeeded()
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(store.selectedProvider.id, anchor: .center)
}
}
.onChange(of: viewportGeo.size.width) { _, newWidth in
stripViewportWidth = newWidth
}
.onChange(of: store.selectedProvider) { _, newProvider in
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(newProvider.id, anchor: .center)
}
}
.onDisappear {
removeScrollWheelMonitorIfNeeded()
}
}
}
.frame(height: 38)
}
private var periodAll: MenubarPayload {
store.periodAllPayload ?? store.payload
}
private var visibleFilters: [ProviderFilter] {
// Tabs reflect the SELECTED range: every provider with usage (cost > 0)
// in the period, ordered by usage. Providers with no usage in the range
// are omitted. `.all` always leads. cost(for:) reads periodAll, so this
// updates as the user switches periods.
let costs = Dictionary(uniqueKeysWithValues: ProviderFilter.allCases.map { ($0, cost(for: $0) ?? 0) })
let detected = ProviderFilter.allCases.filter { filter in
filter == .all || (costs[filter] ?? 0) > 0
}
return detected.sorted { a, b in
if a == .all { return true }
if b == .all { return false }
let ca = costs[a, default: 0]
let cb = costs[b, default: 0]
if ca != cb { return ca > cb }
return a.rawValue < b.rawValue
}
}
private func cost(for filter: ProviderFilter) -> Double? {
let data = periodAll
if filter == .all { return data.current.cost }
let providers = Dictionary(
data.current.providers.map { ($0.key.lowercased(), $0.value) },
uniquingKeysWith: +
)
return filter.providerKeys.reduce(0.0) { sum, key in
sum + (providers[key] ?? 0)
}
}
private var currentFilterIndex: Int {
visibleFilters.firstIndex(of: store.selectedProvider) ?? 0
}
private var canMoveBackward: Bool { currentFilterIndex > 0 }
private var canMoveForward: Bool { currentFilterIndex < visibleFilters.count - 1 }
private var isOverflowing: Bool { stripContentWidth > (stripViewportWidth - 30) }
private func selectAdjacentProvider(direction: Int, proxy: ScrollViewProxy) {
guard !visibleFilters.isEmpty else { return }
let targetIndex = min(max(currentFilterIndex + direction, 0), visibleFilters.count - 1)
let target = visibleFilters[targetIndex]
store.switchTo(provider: target)
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(target.id, anchor: .center)
}
}
/// Standard mouse wheels emit vertical-only scroll deltas, which a horizontal
/// `ScrollView` ignores. While the cursor is over the strip we transpose
/// vertical-axis scroll fields onto the horizontal axis so the underlying
/// NSScrollView receives a real horizontal delta. Trackpad events (precise
/// deltas, with native horizontal component) are passed through untouched
/// so vertical scrolling elsewhere in the popover is unaffected.
private func installScrollWheelMonitorIfNeeded() {
guard scrollWheelMonitor == nil else { return }
scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
guard AgentTabStripScrollState.shared.isStripHovered,
!event.hasPreciseScrollingDeltas,
abs(event.scrollingDeltaX) < 0.001,
abs(event.scrollingDeltaY) > 0,
let cg = event.cgEvent?.copy() else {
return event
}
let lineDeltaY = cg.getIntegerValueField(.scrollWheelEventDeltaAxis1)
let pointDeltaY = cg.getDoubleValueField(.scrollWheelEventPointDeltaAxis1)
let fixedDeltaY = cg.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1)
cg.setIntegerValueField(.scrollWheelEventDeltaAxis1, value: 0)
cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis1, value: 0)
cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1, value: 0)
cg.setIntegerValueField(.scrollWheelEventDeltaAxis2, value: lineDeltaY)
cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis2, value: pointDeltaY)
cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis2, value: fixedDeltaY)
return NSEvent(cgEvent: cg) ?? event
}
}
private func removeScrollWheelMonitorIfNeeded() {
if let monitor = scrollWheelMonitor {
NSEvent.removeMonitor(monitor)
scrollWheelMonitor = nil
}
}
}
private struct AgentTab: View {
let filter: ProviderFilter
let cost: Double?
let isActive: Bool
let quota: QuotaSummary?
let onTap: () -> Void
@State private var hoverPopoverShown = false
@State private var hoverEnterTask: DispatchWorkItem?
@State private var hoverExitTask: DispatchWorkItem?
@State private var clickDismissed = false
/// Providers whose AgentTab chip reserves a 3pt bar slot underneath the
/// label, even when not yet connected. Driven by which providers we
/// actually implement live-quota fetching for in AppStore.quotaSummary.
static func providerSupportsQuota(_ filter: ProviderFilter) -> Bool {
switch filter {
case .claude, .codex: return true
default: return false
}
}
var body: some View {
VStack(spacing: 3) {
HStack(spacing: 5) {
Text(filter.rawValue)
.font(.system(size: 11.5, weight: .medium))
.tracking(-0.05)
if let cost, cost > 0 {
Text(cost.asCompactCurrency())
.font(.codeMono(size: 10.5, weight: .medium))
.foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary))
.tracking(-0.2)
}
}
if quota != nil {
AgentTabQuotaBar(quota: quota, isActive: isActive)
.frame(height: 3)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isActive ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.08)))
)
.foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
.contentShape(Rectangle())
.onTapGesture {
hoverPopoverShown = false
hoverEnterTask?.cancel()
clickDismissed = true
onTap()
}
.onHover { hovering in
hoverEnterTask?.cancel()
hoverExitTask?.cancel()
if !hovering {
clickDismissed = false
let task = DispatchWorkItem { hoverPopoverShown = false }
hoverExitTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: task)
} else if !clickDismissed, quota != nil {
let task = DispatchWorkItem { hoverPopoverShown = true }
hoverEnterTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: task)
}
}
.popover(isPresented: $hoverPopoverShown) {
if let quota {
QuotaDetailPopover(quota: quota)
}
}
}
}
/// Thin progress bar drawn inside an AgentTab chip when that provider has a live quota
/// source. Width matches the chip; color shifts green amber red at 70% / 90%.
private struct AgentTabQuotaBar: View {
let quota: QuotaSummary?
let isActive: Bool
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(trackColor)
if let percent = filledFraction {
Capsule()
.fill(barColor)
.frame(width: max(2, geo.size.width * CGFloat(percent)))
.animation(.easeOut(duration: 0.25), value: percent)
}
if case .terminalFailure = quota?.connection {
// Hatched/red strip to telegraph "broken; reconnect needed".
Capsule()
.fill(Color.red.opacity(0.7))
}
}
}
}
private var filledFraction: Double? {
guard let pct = quota?.primary?.percent else { return nil }
return min(max(pct, 0), 1)
}
private var barColor: Color {
guard let pct = quota?.primary?.percent else { return .clear }
switch QuotaSummary.severity(for: pct) {
case .normal: return isActive ? Color.white : Color.green.opacity(0.85)
case .warning: return Color.yellow
case .critical: return Color.orange
case .danger: return Color.red
}
}
private var trackColor: Color {
isActive ? Color.white.opacity(0.20) : Color.secondary.opacity(0.18)
}
}
private struct QuotaDetailPopover: View {
let quota: QuotaSummary
var body: some View {
VStack(alignment: .leading, spacing: 8) {
switch quota.connection {
case .terminalFailure(let reason):
terminalFailureCard(reason: reason)
case .disconnected:
Text(disconnectedMessage)
.font(.system(size: 11))
.foregroundStyle(.secondary)
case .loading where quota.details.isEmpty:
Text("Loading…")
.font(.system(size: 11))
.foregroundStyle(.secondary)
default:
rowsCard
}
}
.padding(12)
.frame(width: 260)
}
private var disconnectedMessage: String {
switch quota.providerFilter {
case .codex: return "Sign in with `codex` (ChatGPT mode) to track quota."
case .claude: return "Sign in to Claude Code to track quota."
default: return "Sign in to track quota."
}
}
private var rowsCard: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text("\(quota.providerFilter.rawValue) usage")
.font(.system(size: 11, weight: .semibold))
if case .stale = quota.connection {
Text("stale")
.font(.system(size: 9.5))
.foregroundStyle(.secondary)
} else if case .transientFailure = quota.connection {
Text("retrying")
.font(.system(size: 9.5))
.foregroundStyle(.orange)
}
Spacer()
if let plan = quota.planLabel, !plan.isEmpty {
Text(plan)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.12))
)
// Size to content. Plan names are bounded short strings
// ("Max 20x", "Pro Lite", "Free Workspace"); a forced
// maxWidth was making short labels look stretched.
.fixedSize(horizontal: true, vertical: false)
}
}
ForEach(Array(quota.details.enumerated()), id: \.offset) { _, w in
QuotaDetailRow(window: w)
}
if !quota.footerLines.isEmpty {
Divider()
.padding(.top, 2)
ForEach(Array(quota.footerLines.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
}
}
private func terminalFailureCard(reason: String?) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(reconnectTitle)
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.red)
Text(reason ?? defaultReconnectReason)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
Text(reconnectInstruction)
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
private var reconnectTitle: String {
switch quota.providerFilter {
case .codex: return "Reconnect Codex"
default: return "Reconnect Claude"
}
}
private var defaultReconnectReason: String {
switch quota.providerFilter {
case .codex: return "Refresh token rejected by OpenAI."
default: return "Refresh token rejected by Anthropic."
}
}
private var reconnectInstruction: String {
switch quota.providerFilter {
case .codex: return "Run `codex login` in your terminal, then click Reconnect."
default: return "Open Claude Code in your terminal and type `/login`, then click Reconnect."
}
}
}
private struct QuotaDetailRow: View {
let window: QuotaSummary.Window
var body: some View {
HStack(spacing: 8) {
Text(window.label)
.font(.system(size: 10.5))
.frame(width: 92, alignment: .leading)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.18))
Capsule()
.fill(barColor)
.frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1))))
}
}
.frame(height: 4)
Text(window.percentLabel)
.font(.codeMono(size: 10.5, weight: .medium))
.frame(width: 36, alignment: .trailing)
if !window.resetsInLabel.isEmpty {
Text(window.resetsInLabel)
.font(.codeMono(size: 10))
.foregroundStyle(.secondary)
.frame(width: 50, alignment: .trailing)
}
}
}
private var barColor: Color {
switch QuotaSummary.severity(for: window.percent) {
case .normal: return Color.green.opacity(0.85)
case .warning: return Color.yellow
case .critical: return Color.orange
case .danger: return Color.red
}
}
}
extension ProviderFilter {
@MainActor var color: Color {
switch self {
case .all: return Theme.brandAccent
case .claude: return Theme.categoricalClaude
case .cline: return Color(red: 0x23/255.0, green: 0x8A/255.0, blue: 0x7E/255.0)
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 .devin: return Color(red: 0x25/255.0, green: 0xA0/255.0, blue: 0x8D/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 .ibmBob: return Color(red: 0x0F/255.0, green: 0x62/255.0, blue: 0xFE/255.0)
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0)
case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/255.0)
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0)
case .qwen: return Color(red: 0x61/255.0, green: 0x5E/255.0, blue: 0xEB/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 .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)
case .grok: return Color(red: 0x8E/255.0, green: 0x8E/255.0, blue: 0x93/255.0)
case .hermes: return Color(red: 0xC7/255.0, green: 0x52/255.0, blue: 0x3E/255.0)
case .zcode: return Color(red: 0x52/255.0, green: 0x6E/255.0, blue: 0xD6/255.0)
}
}
}
@@ -0,0 +1,287 @@
import SwiftUI
/// Three-category insights panel: wins, improvements, risks.
/// Wins/risks are derived from current + history; improvements come from the optimize findings.
struct FindingsSection: View {
@Environment(AppStore.self) private var store
@State private var isExpanded: Bool = true
var body: some View {
let groups = computeTipGroups(payload: store.payload)
if groups.allSatisfy({ $0.items.isEmpty }) { return AnyView(EmptyView()) }
return AnyView(
VStack(alignment: .leading, spacing: 8) {
Button {
withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() }
} label: {
HStack(alignment: .firstTextBaseline) {
HStack(spacing: 6) {
Image(systemName: "lightbulb.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.brandAccent)
Text("Tips for you")
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(.primary)
}
Spacer()
Text("\(groups.flatMap { $0.items }.count) signals")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .semibold))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.opacity(0.55)
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded {
VStack(alignment: .leading, spacing: 10) {
ForEach(groups) { group in
if !group.items.isEmpty {
TipsGroup(group: group)
}
}
if store.payload.optimize.findingCount > 0 {
Button {
openOptimize()
} label: {
HStack(spacing: 4) {
Text("Open Full Optimize")
.font(.system(size: 11.5, weight: .semibold))
Image(systemName: "arrow.forward")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(Theme.brandAccent)
}
.buttonStyle(.plain)
}
}
.transition(.opacity)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.secondary.opacity(0.06))
)
.padding(.horizontal, 14)
.padding(.vertical, 8)
)
}
private func openOptimize() {
TerminalLauncher.open(subcommand: ["optimize"])
}
}
private struct TipsGroup: View {
let group: TipGroup
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 5) {
Image(systemName: group.icon)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(group.color)
Text(group.label)
.font(.system(size: 10.5, weight: .semibold))
.foregroundStyle(group.color)
.textCase(.uppercase)
.tracking(0.4)
}
VStack(alignment: .leading, spacing: 4) {
ForEach(group.items) { item in
HStack(alignment: .firstTextBaseline, spacing: 6) {
Circle().fill(group.color).frame(width: 3, height: 3).padding(.top, 4)
Text(item.text)
.font(.system(size: 11.5))
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
if let trailing = item.trailing {
Text(trailing)
.font(.codeMono(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.tracking(-0.2)
}
}
}
}
}
}
}
private struct TipGroup: Identifiable {
let id = UUID()
let label: String
let icon: String
let color: Color
let items: [TipItem]
}
private struct TipItem: Identifiable {
let id = UUID()
let text: String
let trailing: String?
}
@MainActor private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] {
let stats = computeHistoryStats(history: payload.history.daily)
// What's working
var wins: [TipItem] = []
let cacheHit = payload.current.cacheHitPercent
if cacheHit >= 80 {
wins.append(TipItem(
text: "Cache hit at \(Int(cacheHit))% — most prompts reuse cache",
trailing: nil
))
}
if let oneShot = payload.current.oneShotRate, oneShot >= 0.75 {
wins.append(TipItem(
text: "\(Int(oneShot * 100))% one-shot — edits landing first try",
trailing: nil
))
}
if let delta = stats.weekDeltaPercent, delta < -10 {
wins.append(TipItem(
text: "Spend down \(Int(abs(delta)))% vs last 7 days",
trailing: nil
))
}
if stats.activeStreakDays >= 5 {
wins.append(TipItem(
text: "\(stats.activeStreakDays)-day usage streak",
trailing: nil
))
}
// What to improve (existing optimize findings)
var improvements: [TipItem] = []
for finding in payload.optimize.topFindings.prefix(3) {
improvements.append(TipItem(
text: finding.title,
trailing: finding.savingsUSD.asCompactCurrency()
))
}
// Risks
var risks: [TipItem] = []
if let delta = stats.weekDeltaPercent, delta > 25 {
risks.append(TipItem(
text: "Spend up \(Int(delta))% vs prior 7 days",
trailing: nil
))
}
if cacheHit > 0 && cacheHit < 50 {
risks.append(TipItem(
text: "Cache hit only \(Int(cacheHit))% — paying for cold prompts",
trailing: nil
))
}
if let oneShot = payload.current.oneShotRate, oneShot < 0.5 {
risks.append(TipItem(
text: "\(Int(oneShot * 100))% one-shot — lots of iteration",
trailing: nil
))
}
if let projected = stats.projectedMonth, let prevMonth = stats.previousMonthTotal, projected > prevMonth * 1.3 {
risks.append(TipItem(
text: "On pace for \(projected.asCompactCurrency()) this month (+\(Int(((projected - prevMonth) / prevMonth) * 100))% vs last)",
trailing: nil
))
}
return [
TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: Theme.brandAccent, items: wins),
TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: Theme.brandAccent, items: improvements),
TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: Theme.brandAccent, items: risks),
]
}
private struct HistoryStats {
let weekDeltaPercent: Double?
let activeStreakDays: Int
let projectedMonth: Double?
let previousMonthTotal: Double?
}
private func computeHistoryStats(history: [DailyHistoryEntry]) -> HistoryStats {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .current
let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = .current
return f
}()
let now = Date()
let today = calendar.startOfDay(for: now)
let costByDate = Dictionary(history.map { ($0.date, $0.cost) }, uniquingKeysWith: +)
let lastWeekStart = calendar.date(byAdding: .day, value: -6, to: today)
let priorWeekStart = calendar.date(byAdding: .day, value: -13, to: today)
let priorWeekEnd = calendar.date(byAdding: .day, value: -7, to: today)
var weekDeltaPercent: Double? = nil
if let lws = lastWeekStart, let pws = priorWeekStart, let pwe = priorWeekEnd {
let lwsStr = formatter.string(from: lws)
let pwsStr = formatter.string(from: pws)
let pweStr = formatter.string(from: pwe)
let thisWeek = history.filter { $0.date >= lwsStr }.reduce(0.0) { $0 + $1.cost }
let prior = history.filter { $0.date >= pwsStr && $0.date <= pweStr }.reduce(0.0) { $0 + $1.cost }
if prior > 0 {
weekDeltaPercent = ((thisWeek - prior) / prior) * 100
}
}
var streak = 0
for offset in 0..<60 {
guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { break }
let key = formatter.string(from: d)
if (costByDate[key] ?? 0) > 0 { streak += 1 } else { break }
}
var projectedMonth: Double? = nil
var previousMonthTotal: Double? = nil
let comps = calendar.dateComponents([.year, .month, .day], from: now)
if
let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)),
let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)
{
let firstStr = formatter.string(from: firstOfMonth)
let mtd = history.filter { $0.date >= firstStr }.reduce(0.0) { $0 + $1.cost }
let dayOfMonth = comps.day ?? 1
if dayOfMonth > 0 {
projectedMonth = (mtd / Double(dayOfMonth)) * Double(rangeOfMonth.count)
}
if
let prevMonth = calendar.date(byAdding: .month, value: -1, to: firstOfMonth),
let prevRange = calendar.range(of: .day, in: .month, for: prevMonth),
let prevFirst = calendar.date(from: DateComponents(
year: calendar.component(.year, from: prevMonth),
month: calendar.component(.month, from: prevMonth),
day: 1
)),
let prevLast = calendar.date(byAdding: .day, value: prevRange.count - 1, to: prevFirst)
{
let prevFirstStr = formatter.string(from: prevFirst)
let prevLastStr = formatter.string(from: prevLast)
let prevTotal = history.filter { $0.date >= prevFirstStr && $0.date <= prevLastStr }
.reduce(0.0) { $0 + $1.cost }
if prevTotal > 0 { previousMonthTotal = prevTotal }
}
}
return HistoryStats(
weekDeltaPercent: weekDeltaPercent,
activeStreakDays: streak,
projectedMonth: projectedMonth,
previousMonthTotal: previousMonthTotal
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
import SwiftUI
struct HeroSection: View {
@Environment(AppStore.self) private var store
var body: some View {
VStack(alignment: .leading, spacing: 8) {
SectionCaption(text: caption)
HStack(alignment: .firstTextBaseline) {
Text(heroText)
.font(.system(size: 32, weight: .semibold, design: .rounded))
.monospacedDigit()
.tracking(-1)
.foregroundStyle(
LinearGradient(
colors: [Theme.brandAccent, Theme.brandAccentDeep],
startPoint: .top,
endPoint: .bottom
)
)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
if store.displayMetric == .tokens {
HStack(spacing: 2) {
Image(systemName: "arrow.up")
.font(.system(size: 9, weight: .semibold))
Text(formatTokens(Double(totals.outputTokens)))
}
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
HStack(spacing: 2) {
Image(systemName: "arrow.down")
.font(.system(size: 9, weight: .semibold))
Text(formatTokens(Double(totals.inputTokens)))
}
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.tertiary)
} else {
Text("\(totals.calls.asThousandsSeparated()) calls")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
Text("\(totals.sessions) sessions")
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.tertiary)
}
}
}
if !store.isDayMode,
store.selectedPeriod == .today,
store.shouldShowDailyBudgetWarning {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10))
Text("Daily budget of \(store.dailyBudgetLabel) exceeded")
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.orange)
.padding(.top, 2)
}
if let usage = combinedUsage {
CombinedDeviceBreakdown(usage: usage, formatTokens: formatTokens)
} else if store.activeScope == .combined, store.lastError != nil {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10))
Text("Combined unavailable · showing local")
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.secondary)
}
if let savingsCaption {
HStack(spacing: 4) {
Image(systemName: "leaf.fill")
.font(.system(size: 10))
Text(savingsCaption)
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.green)
}
}
.padding(.horizontal, 14)
.padding(.top, 10)
.padding(.bottom, 12)
}
private var heroText: String {
if store.displayMetric == .tokens || store.displayMetric == .totalTokens {
let total = Double(totals.totalTokens)
if total >= 1_000_000_000 { return String(format: "%.2fB tok", total / 1_000_000_000) }
if total >= 1_000_000 { return String(format: "%.1fM tok", total / 1_000_000) }
if total >= 1_000 { return String(format: "%.0fK tok", total / 1_000) }
return String(format: "%.0f tok", total)
}
return totals.cost.asCurrency()
}
private var combinedUsage: CombinedUsage? {
guard store.activeScope == .combined else { return nil }
return store.payload.combined
}
private var totals: HeroTotals {
HeroTotals(payload: store.payload, activeScope: store.activeScope)
}
private func formatTokens(_ n: Double) -> String {
if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) }
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
return String(format: "%.0f", n)
}
private var caption: String {
let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
if combinedUsage != nil {
return "Combined · \(label)"
}
if !store.isDayMode && store.selectedPeriod == .today {
return "\(label) · \(todayDate)"
}
return label
}
/// Local-model savings caption shown beneath the hero amount when the
/// user has mapped any local model to a paid baseline via
/// `codeburn model-savings`. Kept as a separate line so actual spend
/// (above) and hypothetical avoided spend (below) never get summed
/// into a misleading "real cost" by the reader.
private var savingsCaption: String? {
guard combinedUsage == nil else { return nil }
let savings = store.payload.current.localModelSavings.totalUSD
guard savings > 0 else { return nil }
return "Saved \(savings.asCurrency()) with local models"
}
private var todayDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEE MMM d"
return formatter.string(from: Date())
}
}
struct HeroTotals: Equatable {
let cost: Double
let calls: Int
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let totalTokens: Int
init(cost: Double, calls: Int, sessions: Int, inputTokens: Int, outputTokens: Int, totalTokens: Int) {
self.cost = cost
self.calls = calls
self.sessions = sessions
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.totalTokens = totalTokens
}
init(payload: MenubarPayload, activeScope: MenubarScope) {
if activeScope == .combined, let combined = payload.combined?.combined {
self.init(
cost: combined.cost,
calls: combined.calls,
sessions: combined.sessions,
inputTokens: combined.inputTokens,
outputTokens: combined.outputTokens,
totalTokens: combined.inputTokens + combined.outputTokens
)
return
}
let current = payload.current
self.init(
cost: current.cost,
calls: current.calls,
sessions: current.sessions,
inputTokens: current.inputTokens,
outputTokens: current.outputTokens,
totalTokens: current.inputTokens + current.outputTokens
)
}
}
private struct CombinedDeviceBreakdown: View {
let usage: CombinedUsage
let formatTokens: (Double) -> String
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
Image(systemName: "desktopcomputer")
.font(.system(size: 10))
Text("\(usage.combined.reachableCount) of \(usage.combined.deviceCount) devices")
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.secondary)
VStack(spacing: 3) {
ForEach(usage.perDevice, id: \.id) { device in
HStack(spacing: 6) {
Image(systemName: device.error == nil ? "circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: device.error == nil ? 5 : 9, weight: .semibold))
.foregroundStyle(device.error == nil ? Color.secondary.opacity(0.75) : Theme.semanticWarning)
.frame(width: 10)
Text(device.local ? "\(device.name) · local" : device.name)
.font(.system(size: 10.5, weight: .medium))
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 6)
Text(device.error == nil ? device.cost.asCurrency() : "Unavailable")
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.secondary)
Text(formatTokens(Double(device.totalTokens)))
.font(.system(size: 10))
.monospacedDigit()
.foregroundStyle(.tertiary)
}
}
}
}
}
}
@@ -0,0 +1,817 @@
import AppKit
import SwiftUI
/// Popover root. Assembles all sections matching the HTML design spec.
struct MenuBarContent: View {
@Environment(AppStore.self) private var store
var body: some View {
VStack(spacing: 0) {
Header()
Divider()
if showAgentTabs {
AgentTabStrip()
Divider()
}
ZStack {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
HeroSection()
Divider().opacity(0.5)
PeriodSegmentedControl()
ScopeSegmentedControl()
Divider().opacity(0.5)
if isFilteredEmpty {
EmptyProviderState(provider: store.selectedProvider, periodLabel: store.selectionLabel)
} else {
HeatmapSection()
.padding(.horizontal, 14)
.padding(.top, 10)
.padding(.bottom, 10)
.zIndex(10)
Divider().opacity(0.5)
ActivitySection()
Divider().opacity(0.5)
ModelsSection()
Divider().opacity(0.5)
ToolingSection()
Divider().opacity(0.5)
FindingsSection()
}
}
}
// Overlay fires only on cold cache for the current key. This
// avoids the 1-frame `$0.00` flash on first-time period/provider
// switches. When the fetch fails (CLI subprocess timeout, parse
// error, etc.), surface a retry card instead of leaving the
// user stuck on a perpetual "Loading..." spinner.
if !store.hasCachedData {
if let err = store.lastError {
FetchErrorOverlay(
error: err,
periodLabel: store.selectionLabel,
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
)
.transition(.opacity)
} else {
BurnLoadingOverlay(periodLabel: store.selectionLabel)
.transition(.opacity)
.task {
var delay: Duration = .seconds(8)
let maxDelay: Duration = .seconds(60)
let maxAttempts = 6
for attempt in 1...maxAttempts {
try? await Task.sleep(for: delay)
guard !Task.isCancelled, !store.hasCachedData else { return }
await store.recoverFromStuckLoading()
if attempt < maxAttempts { delay = min(delay * 2, maxDelay) }
}
guard !Task.isCancelled, !store.hasCachedData else { return }
store.setRecoveryExhausted(for: store.selectionLabel)
}
}
}
}
.frame(height: 520)
.animation(.easeInOut(duration: 0.2), value: store.isLoading)
Divider()
FooterBar()
CLIUpdateBanner()
StarBanner()
}
}
private var isFilteredEmpty: Bool {
guard store.selectedProvider != .all else { return false }
if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false }
if providerHasCostInAllPayload { return false }
return true
}
private var providerHasCostInAllPayload: Bool {
guard let allPayload = store.periodAllPayload else { return false }
let providers = Dictionary(
allPayload.current.providers.map { ($0.key.lowercased(), $0.value) },
uniquingKeysWith: +
)
return store.selectedProvider.providerKeys.contains { key in
(providers[key] ?? 0) > 0
}
}
/// Show the tab row whenever the CLI detected at least one AI coding tool installed
/// on this machine. Hidden only when nothing is detected, which means there's
/// nothing to filter by anyway.
private var showAgentTabs: Bool {
// Sticky: once any cached payload has reported providers, keep the tab strip
// visible. Without this, the strip disappears for one frame on a period
// switch when the new key's payload is still empty.
if store.hasAnyProvidersInCache { return true }
let payload = store.todayPayload ?? store.payload
return !payload.current.providers.isEmpty
}
}
private struct ScopeSegmentedControl: View {
@Environment(AppStore.self) private var store
var body: some View {
HStack(spacing: 8) {
HStack(spacing: 1) {
ForEach(MenubarScope.allCases) { scope in
let isActive = store.activeScope == scope
Button {
store.switchTo(scope: scope)
} label: {
Text(scope.rawValue)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(isActive ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.shadow(color: .black.opacity(isActive ? 0.06 : 0), radius: 1, y: 0.5)
)
}
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(Color.secondary.opacity(0.08))
)
.frame(maxWidth: .infinity)
if store.shouldShowClaudeConfigSelector {
ClaudeConfigPicker()
}
}
.padding(.horizontal, 12)
.padding(.bottom, 10)
}
}
private struct ClaudeConfigPicker: View {
@Environment(AppStore.self) private var store
private var selectedLabel: String {
guard let selected = store.selectedClaudeConfigSourceId,
let option = store.claudeConfigOptions.first(where: { $0.id == selected }) else {
return "All"
}
return option.label
}
var body: some View {
Menu {
Button {
store.switchTo(claudeConfigSourceId: nil)
} label: {
HStack {
if store.selectedClaudeConfigSourceId == nil {
Image(systemName: "checkmark")
}
Text("All")
}
}
Divider()
ForEach(store.claudeConfigOptions) { option in
Button {
store.switchTo(claudeConfigSourceId: option.id)
} label: {
HStack {
if store.selectedClaudeConfigSourceId == option.id {
Image(systemName: "checkmark")
}
Text(option.label)
}
}
}
} label: {
HStack(spacing: 5) {
Image(systemName: "person.crop.circle")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
Text(selectedLabel)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.truncationMode(.tail)
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.secondary)
}
.foregroundStyle(.primary)
.frame(width: 118, height: 26)
.padding(.horizontal, 6)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(Color.secondary.opacity(0.08))
)
.contentShape(Rectangle())
}
.menuStyle(.borderlessButton)
.fixedSize(horizontal: true, vertical: false)
.help("Claude config")
}
}
private struct EmptyProviderState: View {
let provider: ProviderFilter
let periodLabel: String
var body: some View {
VStack(spacing: 10) {
Image(systemName: "tray")
.font(.system(size: 26))
.foregroundStyle(.tertiary)
Text("No \(provider.rawValue) data for \(periodLabel)")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 60)
}
}
/// Shown when a fetch failed and the cache is still empty for this key. The
/// user previously sat on the "Loading" spinner forever the popover had
/// no path to recover beyond the next 30s tick (which would just re-fail).
/// Now they see what broke and can retry directly.
private struct FetchErrorOverlay: View {
let error: String
let periodLabel: String
let retry: () -> Void
var body: some View {
ZStack {
Rectangle().fill(.ultraThinMaterial)
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 28))
.foregroundStyle(Theme.brandAccent)
Text("Couldn't load \(periodLabel)")
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(.primary)
Text(displayError)
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 280)
.lineLimit(3)
Button("Retry", action: retry)
.buttonStyle(.borderedProminent)
.tint(Theme.brandAccent)
.controlSize(.small)
}
.padding(.horizontal, 20)
}
}
/// Strip the leading subprocess noise that creeps into NSError descriptions
/// so the visible message is the actual cause, not the framework wrapper.
private var displayError: String {
let trimmed = error.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.count <= 240 { return trimmed }
return String(trimmed.prefix(240)) + ""
}
}
/// Translucent overlay that blurs whatever's behind it (the previous tab/period content)
/// and centers an animated burning flame -- the brand mark filling up bottom-to-top in
/// yelloworangered, looping.
private struct BurnLoadingOverlay: View {
let periodLabel: String
@State private var fillProgress: CGFloat = 0
@State private var glowing: Bool = false
private let flameSize: CGFloat = 64
var body: some View {
ZStack {
// Blur backdrop -- ultraThinMaterial uses live blur of underlying content.
Rectangle()
.fill(.ultraThinMaterial)
VStack(spacing: 14) {
BurnFlame(size: flameSize, fillProgress: fillProgress, glowing: glowing)
Text("Loading \(periodLabel)")
.font(.system(size: 11.5, weight: .medium))
.foregroundStyle(.secondary)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) {
fillProgress = 1.0
}
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
glowing = true
}
}
}
}
private struct BurnFlame: View {
let size: CGFloat
let fillProgress: CGFloat
let glowing: Bool
var body: some View {
ZStack {
// Soft outer glow that pulses, matching the brand terracotta palette.
Image(systemName: "flame.fill")
.font(.system(size: size, weight: .regular))
.foregroundStyle(Theme.brandAccentGlow.opacity(glowing ? 0.55 : 0.20))
.blur(radius: glowing ? 14 : 6)
// Empty (cool) flame as base
Image(systemName: "flame")
.font(.system(size: size, weight: .regular))
.foregroundStyle(Theme.brandAccent.opacity(0.25))
// Burning gradient (brand orange) masked by an animated bottom-up rectangle
Image(systemName: "flame.fill")
.font(.system(size: size, weight: .regular))
.foregroundStyle(
LinearGradient(
colors: [
Theme.brandAccentGlow,
Theme.brandAccentLight,
Theme.brandAccent,
Theme.brandAccentDeep
],
startPoint: .bottom,
endPoint: .top
)
)
.mask(
GeometryReader { geo in
Rectangle()
.frame(height: geo.size.height * fillProgress)
.frame(maxHeight: .infinity, alignment: .bottom)
}
)
}
.frame(width: size, height: size)
}
}
private struct Header: View {
@Environment(UpdateChecker.self) private var updateChecker
@Environment(AppStore.self) private var store
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
VStack(alignment: .leading, spacing: 1) {
(
Text("Code").foregroundStyle(.primary)
+ Text("Burn").foregroundStyle(Theme.brandEmber)
)
.font(.system(size: 13, weight: .semibold))
.tracking(-0.15)
Text("AI Coding Cost Tracker")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
Spacer()
if updateChecker.updateAvailable || updateChecker.updateError != nil {
UpdateBadge()
}
AccentPicker()
}
// Compact warning row when any connected provider crosses 70%.
// Lists all warning providers with their worst-window percent so
// the user knows whether to slow down on Claude, Codex, or both.
QuotaWarningRow(status: store.aggregateQuotaStatus)
}
.padding(.horizontal, 14)
.padding(.top, 10)
.padding(.bottom, 8)
}
}
private struct QuotaWarningRow: View {
let status: AppStore.AggregateQuotaStatus
var body: some View {
if !status.warnings.isEmpty {
HStack(spacing: 6) {
Image(systemName: severityIcon)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(severityColor)
Text(message)
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(severityColor)
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(severityColor.opacity(0.12))
)
}
}
private var message: String {
let parts = status.warnings.map { "\($0.name) \(Int($0.percent.rounded()))%" }
if parts.count == 1 {
// Reads "Claude over limit (105%)" when any provider exceeds the
// quota cap, instead of the awkward "Claude 105% of quota used".
if case .danger = status.severity {
return "\(status.warnings[0].name) over limit (\(Int(status.warnings[0].percent.rounded()))%)"
}
return "\(parts[0]) of quota used"
}
return parts.joined(separator: " · ")
}
private var severityColor: Color {
switch status.severity {
case .normal: return .secondary
case .warning: return .yellow
case .critical: return .orange
case .danger: return .red
}
}
private var severityIcon: String {
switch status.severity {
case .normal: return "info.circle"
case .warning: return "exclamationmark.circle"
case .critical: return "exclamationmark.triangle"
case .danger: return "octagon"
}
}
}
private struct AccentPicker: View {
@Environment(AppStore.self) private var store
var body: some View {
HStack(spacing: 0) {
if store.showingAccentPicker {
HStack(spacing: 5) {
ForEach(AccentPreset.allCases) { preset in
Button {
withAnimation(.easeInOut(duration: 0.15)) {
store.accentPreset = preset
}
} label: {
Circle()
.fill(preset.base)
.frame(width: 12, height: 12)
.overlay(
Circle()
.stroke(.white.opacity(store.accentPreset == preset ? 0.9 : 0), lineWidth: 1.5)
)
}
.buttonStyle(.plain)
.accessibilityLabel(preset.rawValue)
}
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.secondary.opacity(0.08))
)
.transition(.opacity.combined(with: .move(edge: .trailing)))
}
Button {
withAnimation(.easeInOut(duration: 0.2)) {
store.showingAccentPicker.toggle()
}
} label: {
Circle()
.fill(store.accentPreset.base)
.frame(width: 14, height: 14)
.overlay(
Circle()
.stroke(.white.opacity(0.3), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.accessibilityLabel("Change accent color")
.padding(.leading, 4)
}
}
}
private struct UpdateBadge: View {
@Environment(UpdateChecker.self) private var updateChecker
var body: some View {
Button {
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..." : (updateChecker.updateError == nil ? "Update" : "Failed"))
.font(.system(size: 10, weight: .medium))
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
.buttonStyle(.borderedProminent)
.tint(Theme.brandAccent)
.controlSize(.mini)
.disabled(updateChecker.isUpdating)
.help(updateChecker.updateError ?? "Install the latest menubar build")
}
}
struct FlameMark: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 5)
.fill(
LinearGradient(
colors: [Theme.brandAccentLight, Theme.brandAccentDeep],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.shadow(color: .black.opacity(0.2), radius: 1, y: 0.5)
Image(systemName: "flame.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
}
}
}
struct CLIUpdateBanner: View {
@Environment(UpdateChecker.self) private var updateChecker
var body: some View {
if updateChecker.cliUpdateAvailable {
HStack(spacing: 6) {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.blue)
Text("CLI \(updateChecker.latestCliVersion ?? "") available")
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(.primary)
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(updateChecker.cliUpdateCommand, forType: .string)
} label: {
HStack(spacing: 3) {
Text(updateChecker.cliUpdateCommand)
.font(.system(size: 10, weight: .medium, design: .monospaced))
Image(systemName: "doc.on.doc")
.font(.system(size: 8))
}
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
.help("Copy update command to clipboard")
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.blue.opacity(0.06))
.overlay(alignment: .top) {
Rectangle()
.fill(Color.secondary.opacity(0.18))
.frame(height: 0.5)
}
}
}
}
private let starBannerGitHubURL = URL(string: "https://github.com/getagentseal/codeburn")!
/// Shown at the very bottom on first launch. A small terracotta strip nudges users to star the
/// repo; clicking opens GitHub, clicking the close icon hides it forever (persisted to
/// UserDefaults so it never returns across launches).
struct StarBanner: View {
@AppStorage("codeburn.starBannerDismissed") private var dismissed: Bool = false
var body: some View {
if !dismissed {
HStack(spacing: 8) {
Image(systemName: "star.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(Theme.brandAccent)
Button {
NSWorkspace.shared.open(starBannerGitHubURL)
} label: {
HStack(spacing: 4) {
Text("Enjoying CodeBurn?")
.foregroundStyle(.primary)
Text("Star us on GitHub")
.foregroundStyle(Theme.brandAccent)
.underline(true, pattern: .solid)
}
.font(.system(size: 10.5, weight: .medium))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Spacer()
Button {
dismissed = true
} label: {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.padding(4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Hide this banner")
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Theme.brandAccent.opacity(0.08))
.overlay(alignment: .top) {
Rectangle()
.fill(Color.secondary.opacity(0.18))
.frame(height: 0.5)
}
}
}
}
struct FooterBar: View {
@Environment(AppStore.self) private var store
var body: some View {
HStack(spacing: 6) {
Menu {
ForEach(SupportedCurrency.allCases) { currency in
Button {
applyCurrency(code: currency.rawValue)
} label: {
if currency.rawValue == store.currency {
Label("\(currency.displayName) (\(currency.rawValue))", systemImage: "checkmark")
} else {
Text("\(currency.displayName) (\(currency.rawValue))")
}
}
}
} label: {
Label(store.currency, systemImage: "dollarsign.circle")
.font(.system(size: 11, weight: .medium))
.labelStyle(.titleAndIcon)
}
.menuStyle(.button)
.menuIndicator(.hidden)
.buttonStyle(.bordered)
.controlSize(.small)
.fixedSize()
Button {
refreshNow()
} label: {
Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
.font(.system(size: 11, weight: .medium))
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(store.isLoading)
Menu {
Button("CSV (folder)") { runExport(format: .csv) }
Button("JSON") { runExport(format: .json) }
} label: {
Label("Export", systemImage: "square.and.arrow.down")
.font(.system(size: 11, weight: .medium))
.labelStyle(.titleAndIcon)
}
.menuStyle(.button)
.menuIndicator(.hidden)
.buttonStyle(.bordered)
.controlSize(.small)
.fixedSize()
Spacer()
Text(AppVersion.displayBundleShortVersion)
.font(.system(size: 10, weight: .regular, design: .monospaced))
.foregroundStyle(.tertiary)
Button { openReport() } label: {
Label("Full Report", systemImage: "terminal")
.font(.system(size: 11, weight: .semibold))
.labelStyle(.titleAndIcon)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(Theme.brandAccent)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
private func openReport() {
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" }
var suffix: String { self == .csv ? "" : ".json" }
}
/// Runs `codeburn export` directly into ~/Downloads and reveals the result in Finder. CSV
/// produces a folder of clean one-table-per-file CSVs; JSON produces a single structured
/// file. The CLI is spawned with argv (no shell interpretation), so the output path cannot
/// be abused to inject shell commands even if a pathological value slips through.
private func runExport(format: ExportFormat) {
Task {
let downloads = (NSHomeDirectory() as NSString).appendingPathComponent("Downloads")
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
let base = "codeburn-\(formatter.string(from: Date()))"
let outputPath = (downloads as NSString).appendingPathComponent(base + format.suffix)
let process = CodeburnCLI.makeProcess(subcommand: [
"export", "-f", format.cliName, "-o", outputPath
])
do {
let fmt = format
process.terminationHandler = { proc in
Task { @MainActor in
if proc.terminationStatus == 0 {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)])
} else {
NSLog("CodeBurn: \(fmt.cliName.uppercased()) export exited with status \(proc.terminationStatus)")
}
}
}
try process.run()
} catch {
NSLog("CodeBurn: \(format.cliName.uppercased()) export failed: \(error)")
}
}
}
/// Instant-feeling currency switch. Updates the symbol and any cached FX rate on the main
/// thread right away so the UI redraws the next frame, then fetches a fresh rate in the
/// background. CLI config is persisted so other codeburn commands stay in sync.
private func applyCurrency(code: String) {
let symbol = CurrencyState.symbolForCode(code)
Task {
let cached = await FXRateCache.shared.cachedRate(for: code)
if let cached {
store.currency = code
CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
}
let fresh = await FXRateCache.shared.rate(for: code)
if let rate = fresh ?? cached {
store.currency = code
CurrencyState.shared.apply(code: code, rate: rate, symbol: symbol)
}
}
CLICurrencyConfig.persist(code: code)
}
}
@@ -0,0 +1,119 @@
import SwiftUI
struct ModelsSection: View {
@Environment(AppStore.self) private var store
@State private var isExpanded: Bool = true
// Only surface the Saved column when something was actually saved by a
// local-model mapping. With no mapping it would be an unlabeled column of
// dashes, so we drop it entirely and keep the plain Cost / Calls layout.
private var showSavings: Bool {
store.payload.current.topModels.contains { $0.savingsUSD > 0 }
}
var body: some View {
CollapsibleSection(
caption: "Models",
isExpanded: $isExpanded,
trailing: {
HStack(spacing: 8) {
Text("Cost").frame(minWidth: 54, alignment: .trailing)
if showSavings {
Text("Saved").frame(minWidth: 54, alignment: .trailing)
}
Text("Calls").frame(minWidth: 52, alignment: .trailing)
}
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
.tracking(-0.05)
}
) {
VStack(alignment: .leading, spacing: 7) {
let maxCost = max(store.payload.current.topModels.map(\.cost).max() ?? 1, 0.01)
ForEach(store.payload.current.topModels, id: \.name) { model in
ModelRow(model: model, maxCost: maxCost, showSavings: showSavings)
}
TokensLine()
.padding(.top, 5)
}
}
}
}
private struct ModelRow: View {
let model: ModelEntry
let maxCost: Double
let showSavings: Bool
var body: some View {
HStack(spacing: 8) {
// Bar tracks actual cost; for local models the cost is $0 and the
// bar will be empty. Saved counterfactual (if any) renders as
// green text in the saved column, never summed into the bar.
FixedBar(fraction: model.cost / maxCost)
.frame(width: 56, height: 6)
Text(model.name)
.font(.system(size: 12.5, weight: .medium))
.frame(maxWidth: .infinity, alignment: .leading)
Text(model.cost.asCompactCurrency())
.font(.codeMono(size: 12, weight: .medium))
.tracking(-0.2)
.frame(minWidth: 54, alignment: .trailing)
if showSavings {
Text(model.savingsUSD > 0 ? model.savingsUSD.asCompactCurrency() : "")
.font(.codeMono(size: 12))
.tracking(-0.2)
.foregroundStyle(model.savingsUSD > 0 ? Color.green : Color.secondary)
.frame(minWidth: 54, alignment: .trailing)
}
Text("\(model.calls)")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 52, alignment: .trailing)
}
.padding(.horizontal, 2)
.padding(.vertical, 1)
}
}
private struct TokensLine: View {
@Environment(AppStore.self) private var store
var body: some View {
let t = store.payload.current
let cacheHit = String(format: "%.0f", t.cacheHitPercent)
HStack(spacing: 4) {
Text("Tokens")
.foregroundStyle(.tertiary)
Text(formatTokens(t.inputTokens) + " in")
.foregroundStyle(.secondary)
Text("·")
.foregroundStyle(.tertiary)
Text(formatTokens(t.outputTokens) + " out")
.foregroundStyle(.secondary)
Text("·")
.foregroundStyle(.tertiary)
Text(cacheHit + "% cache hit")
.foregroundStyle(.secondary)
Spacer()
}
.font(.system(size: 10.5))
.monospacedDigit()
}
private func formatTokens(_ n: Int) -> String {
if n >= 1_000_000 {
return String(format: "%.1fM", Double(n) / 1_000_000)
} else if n >= 1_000 {
return String(format: "%.1fK", Double(n) / 1_000)
}
return "\(n)"
}
}
@@ -0,0 +1,304 @@
import SwiftUI
struct PeriodSegmentedControl: View {
@Environment(AppStore.self) private var store
@State private var showingCalendar = false
var body: some View {
HStack(spacing: 1) {
ForEach(Period.allCases) { period in
let isActive = !store.isDayMode && store.selectedPeriod == period
Button {
store.switchTo(period: period)
} label: {
Text(period.rawValue)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(isActive ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.shadow(color: .black.opacity(isActive ? 0.06 : 0), radius: 1, y: 0.5)
)
}
Button {
showingCalendar.toggle()
} label: {
Image(systemName: "calendar")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(store.isDayMode ? Theme.brandAccent : .secondary)
.frame(width: 28)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(store.isDayMode ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.shadow(color: .black.opacity(store.isDayMode ? 0.06 : 0), radius: 1, y: 0.5)
)
.popover(isPresented: $showingCalendar, arrowEdge: .bottom) {
CalendarPopover(isPresented: $showingCalendar)
.environment(store)
}
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(Color.secondary.opacity(0.08))
)
.padding(.horizontal, 12)
.padding(.top, 6)
.padding(.bottom, 10)
}
}
private struct CalendarPopover: View {
@Environment(AppStore.self) private var store
@Binding var isPresented: Bool
@State private var displayMonth = Date()
@State private var pending: Set<String> = []
private let calendar = Calendar.current
private let weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
private let cellSize: CGFloat = 30
var body: some View {
VStack(spacing: 0) {
HStack {
Button { shiftMonth(-1) } label: {
Image(systemName: "chevron.left")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.secondary)
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Spacer()
Text(monthYearLabel)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
Button { shiftMonth(1) } label: {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(canGoForward ? .secondary : Color.secondary.opacity(0.3))
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!canGoForward)
}
.padding(.horizontal, 10)
.padding(.top, 10)
.padding(.bottom, 6)
HStack(spacing: 0) {
ForEach(weekdays, id: \.self) { day in
Text(day)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.tertiary)
.frame(width: cellSize, height: 16)
}
}
.padding(.bottom, 2)
LazyVGrid(columns: Array(repeating: GridItem(.fixed(cellSize), spacing: 0), count: 7), spacing: 2) {
ForEach(dayCells, id: \.id) { cell in
DayCellView(
cell: cell,
isSelected: pending.contains(cell.dateString),
isToday: cell.dateString == todayString,
isFuture: cell.dateString > todayString
) {
toggleDay(cell.dateString)
}
}
}
.padding(.horizontal, 6)
HStack(spacing: 8) {
if !pending.isEmpty {
Button("Clear") {
pending = []
}
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.buttonStyle(.plain)
}
Spacer()
Text(selectionSummary)
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.lineLimit(1)
Spacer()
Button {
if !pending.isEmpty {
store.switchTo(days: pending)
} else {
store.switchTo(period: store.selectedPeriod)
}
isPresented = false
} label: {
Text("Done")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(pending.isEmpty ? Color.secondary.opacity(0.3) : Theme.brandAccent)
)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 10)
}
.frame(width: cellSize * 7 + 24)
.onAppear {
pending = store.selectedDays
if let first = store.selectedDays.sorted().first,
let date = AppStore.dayFormatter.date(from: first) {
displayMonth = date
}
}
}
private var todayString: String {
AppStore.dayString(from: Date())
}
private var monthYearLabel: String {
let f = DateFormatter()
f.dateFormat = "MMMM yyyy"
return f.string(from: displayMonth)
}
private var canGoForward: Bool {
let nextMonth = calendar.date(byAdding: .month, value: 1, to: displayMonth) ?? displayMonth
return calendar.startOfDay(for: nextMonth) <= calendar.startOfDay(for: Date())
}
private var selectionSummary: String {
if pending.isEmpty { return "Pick dates" }
if pending.count == 1 { return "1 day" }
return "\(pending.count) days"
}
private func shiftMonth(_ delta: Int) {
if let next = calendar.date(byAdding: .month, value: delta, to: displayMonth) {
displayMonth = next
}
}
private func toggleDay(_ day: String) {
guard day <= todayString else { return }
if pending.contains(day) {
pending.remove(day)
} else {
pending.insert(day)
}
}
var dayCells: [DayCell] {
let comps = calendar.dateComponents([.year, .month], from: displayMonth)
guard let firstOfMonth = calendar.date(from: comps),
let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { return [] }
var weekdayOfFirst = calendar.component(.weekday, from: firstOfMonth) - 2
if weekdayOfFirst < 0 { weekdayOfFirst += 7 }
var cells: [DayCell] = []
for offset in stride(from: -weekdayOfFirst, to: 0, by: 1) {
if let date = calendar.date(byAdding: .day, value: offset, to: firstOfMonth) {
let d = calendar.component(.day, from: date)
cells.append(DayCell(id: "prev-\(offset)", day: d, dateString: AppStore.dayString(from: date), isCurrentMonth: false))
}
}
for day in range {
if let date = calendar.date(byAdding: .day, value: day - 1, to: firstOfMonth) {
cells.append(DayCell(id: "cur-\(day)", day: day, dateString: AppStore.dayString(from: date), isCurrentMonth: true))
}
}
let remainder = (7 - cells.count % 7) % 7
if let lastOfMonth = calendar.date(byAdding: .day, value: range.count - 1, to: firstOfMonth) {
for i in 1...max(remainder, 1) {
if let date = calendar.date(byAdding: .day, value: i, to: lastOfMonth) {
let d = calendar.component(.day, from: date)
cells.append(DayCell(id: "next-\(i)", day: d, dateString: AppStore.dayString(from: date), isCurrentMonth: false))
}
}
}
if cells.count % 7 != 0 {
cells = Array(cells.prefix(cells.count - cells.count % 7))
}
return cells
}
}
private struct DayCell: Identifiable {
let id: String
let day: Int
let dateString: String
let isCurrentMonth: Bool
}
private struct DayCellView: View {
let cell: DayCell
let isSelected: Bool
let isToday: Bool
let isFuture: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text("\(cell.day)")
.font(.system(size: 11, weight: isToday ? .bold : .regular))
.foregroundStyle(foregroundColor)
.frame(width: 28, height: 28)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(backgroundColor)
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(isToday && !isSelected ? Theme.brandAccent.opacity(0.5) : .clear, lineWidth: 1)
)
}
.buttonStyle(.plain)
.disabled(isFuture)
}
private var foregroundColor: Color {
if isFuture { return Color.secondary.opacity(0.25) }
if isSelected { return .white }
if !cell.isCurrentMonth { return Color.secondary.opacity(0.4) }
if isToday { return Theme.brandAccent }
return .primary
}
private var backgroundColor: Color {
if isSelected { return Theme.brandAccent }
return .clear
}
}
@@ -0,0 +1,85 @@
import SwiftUI
struct SectionCaption: View {
let text: String
var body: some View {
HStack(spacing: 5) {
Circle()
.fill(Theme.brandAccent.opacity(0.7))
.frame(width: 3, height: 3)
Text(text)
.font(.system(size: 11.5, weight: .medium))
.foregroundStyle(.secondary)
.tracking(-0.1)
}
}
}
/// Collapsible section shell with a clickable caption, optional inline trailing
/// view (e.g. column headers), and a chevron.
struct CollapsibleSection<Trailing: View, Content: View>: View {
let caption: String
@Binding var isExpanded: Bool
let trailing: Trailing
let content: Content
init(
caption: String,
isExpanded: Binding<Bool>,
@ViewBuilder trailing: () -> Trailing,
@ViewBuilder content: () -> Content
) {
self.caption = caption
self._isExpanded = isExpanded
self.trailing = trailing()
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 7) {
Button {
withAnimation(.easeInOut(duration: 0.18)) {
isExpanded.toggle()
}
} label: {
HStack(spacing: 8) {
HStack(spacing: 5) {
Circle()
.fill(Theme.brandAccent.opacity(0.7))
.frame(width: 3, height: 3)
Text(caption)
.font(.system(size: 11.5, weight: .medium))
.tracking(-0.1)
}
Spacer()
trailing
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .semibold))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.opacity(0.55)
}
.foregroundStyle(.secondary)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded {
content
.transition(.opacity)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 11)
}
}
extension CollapsibleSection where Trailing == EmptyView {
init(
caption: String,
isExpanded: Binding<Bool>,
@ViewBuilder content: () -> Content
) {
self.init(caption: caption, isExpanded: isExpanded, trailing: { EmptyView() }, content: content)
}
}
@@ -0,0 +1,715 @@
import AppKit
import SwiftUI
/// macOS-standard tabbed Settings window. New per-provider sections (Codex,
/// Cursor, Copilot, etc.) plug in as additional tabs. Each tab owns its own
/// concerns; this top-level view only hosts the TabView shell.
struct SettingsView: View {
@Environment(AppStore.self) private var store
var body: some View {
TabView(selection: Binding(get: { store.settingsTab }, set: { store.settingsTab = $0 })) {
GeneralSettingsTab()
.tabItem { Label("General", systemImage: "gearshape") }
.tag("general")
ClaudeSettingsTab()
.tabItem { Label("Claude", systemImage: "brain") }
.tag("claude")
CodexSettingsTab()
.tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") }
.tag("codex")
DevinSettingsTab()
.tabItem { Label("Devin", systemImage: "flame.fill") }
.tag("devin")
AboutSettingsTab()
.tabItem { Label("About", systemImage: "info.circle") }
.tag("about")
}
.frame(width: 520, height: 430)
}
}
// MARK: - General
private struct GeneralSettingsTab: View {
@Environment(AppStore.self) private var store
// "Custom" budget entry state, one per metric (cost in dollars, tokens in
// millions). When custom is active the picker shows "Custom" and a field
// appears for an exact amount.
@State private var costCustom = false
@State private var tokenCustom = false
@State private var costText = ""
@State private var tokenText = ""
// AppStorage (not a computed Binding over UsageRefreshCadence.current):
// a plain UserDefaults write does not invalidate the view, so the picker
// label would never reflect the selection even though the value landed.
@AppStorage(UsageRefreshCadence.defaultsKey)
private var usageRefreshSeconds: Int = UsageRefreshCadence.default.rawValue
private let costPresets: Set<Double> = [25, 50, 100, 200, 500]
private let tokenPresets: Set<Double> = [1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 100_000_000]
private func applyCostBudget() {
store.dailyBudget = max(0, Double(costText.trimmingCharacters(in: .whitespaces)) ?? 0)
}
private func applyTokenBudget() {
let millions = Double(tokenText.trimmingCharacters(in: .whitespaces)) ?? 0
store.dailyTokenBudget = max(0, millions * 1_000_000)
}
private func trimNumber(_ v: Double) -> String {
v == v.rounded() ? String(Int(v)) : String(v)
}
// Help text under the budget picker. When "Custom" is selected but no amount
// has been entered, the budget is effectively 0 (off); call that out so the
// alert does not look armed when it isn't.
private var alertHelpText: String {
let customEmpty = store.isTokenMetric
? (tokenCustom && store.dailyTokenBudget == 0)
: (costCustom && store.dailyBudget == 0)
if customEmpty { return "Enter an amount above, or the alert stays off." }
return "Flame icon turns yellow when today's \(store.isTokenMetric ? "tokens" : "cost") pass the daily budget."
}
var body: some View {
Form {
Section("Display") {
Picker("Currency", selection: Binding(
get: { store.currency },
set: { applyCurrency(code: $0) }
)) {
ForEach(SupportedCurrency.allCases) { currency in
Text("\(currency.rawValue)\(currency.displayName)").tag(currency.rawValue)
}
}
Picker("Metric", selection: Binding(
get: { store.displayMetric },
set: { store.displayMetric = $0 }
)) {
Text("Cost ($)").tag(DisplayMetric.cost)
Text("Tokens (↑↓)").tag(DisplayMetric.tokens)
Text("Total Tokens").tag(DisplayMetric.totalTokens)
Text("Credits (Codex)").tag(DisplayMetric.credits)
Text("Icon Only").tag(DisplayMetric.iconOnly)
}
Picker("Period", selection: Binding(
get: { store.menubarPeriod },
set: { store.setMenubarPeriod($0) }
)) {
ForEach(Period.menubarMetricCases) { period in
Text(period.menubarMetricLabel).tag(period)
}
}
.pickerStyle(.menu)
Picker("Scope", selection: Binding(
get: { store.menubarScope },
set: { store.setMenubarScope($0) }
)) {
ForEach(MenubarScope.allCases) { scope in
Text(scope.rawValue).tag(scope)
}
}
.pickerStyle(.menu)
Picker("Accent", selection: Binding(
get: { store.accentPreset },
set: { store.accentPreset = $0 }
)) {
ForEach(AccentPreset.allCases) { preset in
Text(preset.rawValue).tag(preset)
}
}
}
Section("Usage Refresh") {
Picker("Update every", selection: Binding(
get: { UsageRefreshCadence(rawValue: usageRefreshSeconds) ?? .default },
set: { usageRefreshSeconds = $0.rawValue }
)) {
ForEach(UsageRefreshCadence.allCases) { cadence in
Text(cadence.label).tag(cadence)
}
}
.pickerStyle(.menu)
Text("How often the menubar figure re-reads your local session data. Auto refreshes every 30 seconds while you're plugged in and backs off on battery; Manual only refreshes when you open the popover or click Refresh Now.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Section("Alerts") {
// The budget tracks whatever the menubar metric shows: dollars for
// the Cost metric, tokens for the Tokens / Total Tokens metrics.
// "Custom" reveals a field for an exact amount.
if store.isTokenMetric {
Picker("Daily budget", selection: Binding(
get: { tokenCustom ? -1.0 : store.dailyTokenBudget },
set: { sel in
if sel < 0 {
tokenCustom = true
tokenText = store.dailyTokenBudget > 0 ? trimNumber(store.dailyTokenBudget / 1_000_000) : ""
} else {
tokenCustom = false
store.dailyTokenBudget = sel
}
}
)) {
Text("Off").tag(0.0)
Text("1M").tag(1_000_000.0)
Text("5M").tag(5_000_000.0)
Text("10M").tag(10_000_000.0)
Text("25M").tag(25_000_000.0)
Text("50M").tag(50_000_000.0)
Text("100M").tag(100_000_000.0)
Text("Custom…").tag(-1.0)
}
if tokenCustom {
HStack {
TextField("Amount", text: $tokenText)
.multilineTextAlignment(.trailing)
.onSubmit { applyTokenBudget() }
.onChange(of: tokenText) { _, _ in applyTokenBudget() }
Text("M tokens").foregroundStyle(.secondary)
}
}
} else {
Picker("Daily budget", selection: Binding(
get: { costCustom ? -1.0 : store.dailyBudget },
set: { sel in
if sel < 0 {
costCustom = true
costText = store.dailyBudget > 0 ? trimNumber(store.dailyBudget) : ""
} else {
costCustom = false
store.dailyBudget = sel
}
}
)) {
Text("Off").tag(0.0)
Text("$25").tag(25.0)
Text("$50").tag(50.0)
Text("$100").tag(100.0)
Text("$200").tag(200.0)
Text("$500").tag(500.0)
Text("Custom…").tag(-1.0)
}
if costCustom {
HStack {
Text("$").foregroundStyle(.secondary)
TextField("Amount", text: $costText)
.multilineTextAlignment(.trailing)
.onSubmit { applyCostBudget() }
.onChange(of: costText) { _, _ in applyCostBudget() }
}
}
}
Text(alertHelpText)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.onAppear {
costCustom = store.dailyBudget > 0 && !costPresets.contains(store.dailyBudget)
if costCustom { costText = trimNumber(store.dailyBudget) }
tokenCustom = store.dailyTokenBudget > 0 && !tokenPresets.contains(store.dailyTokenBudget)
if tokenCustom { tokenText = trimNumber(store.dailyTokenBudget / 1_000_000) }
}
}
.formStyle(.grouped)
.padding()
}
private func applyCurrency(code: String) {
let symbol = CurrencyState.symbolForCode(code)
Task {
let cached = await FXRateCache.shared.cachedRate(for: code)
if let cached {
store.currency = code
CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
}
let fresh = await FXRateCache.shared.rate(for: code)
store.currency = code
CurrencyState.shared.apply(code: code, rate: fresh ?? cached, symbol: symbol)
}
CLICurrencyConfig.persist(code: code)
}
}
// MARK: - Claude
private struct ClaudeSettingsTab: View {
@Environment(AppStore.self) private var store
var body: some View {
Form {
Section("Connection") {
ClaudeConnectionRow()
}
Section {
ClaudeConfigDirsSection()
} header: {
Text("Config Directories")
} footer: {
Text("Aggregate usage across multiple Claude config directories (e.g. work and personal accounts). Leave empty to track just the default `~/.claude`. The `CLAUDE_CONFIG_DIRS` environment variable, if set, overrides this list.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Section("Quota Refresh") {
Picker("Update every", selection: Binding(
get: { SubscriptionRefreshCadence.current },
set: { SubscriptionRefreshCadence.current = $0 }
)) {
ForEach(SubscriptionRefreshCadence.allCases) { cadence in
Text(cadence.label).tag(cadence)
}
}
.pickerStyle(.menu)
Text("Anthropic rate-limits this endpoint per account. 2 minutes is plenty for the 5-hour and weekly windows; pick Manual if you only want updates on demand.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Button("Refresh Now") {
Task { await store.refreshSubscription() }
}
}
}
.formStyle(.grouped)
.padding()
}
}
private struct ClaudeConnectionRow: View {
@Environment(AppStore.self) private var store
@State private var showDisconnectConfirm = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: stateIcon)
.font(.system(size: 18))
.foregroundStyle(stateTint)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(stateTitle)
.font(.system(size: 12, weight: .semibold))
Text(stateDetail)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
actionButton
}
.padding(.vertical, 4)
}
private var stateIcon: String {
switch store.subscriptionLoadState {
case .loaded: return "checkmark.circle.fill"
case .terminalFailure: return "exclamationmark.triangle.fill"
case .transientFailure: return "clock.arrow.circlepath"
case .bootstrapping, .loading: return "ellipsis.circle"
case .notBootstrapped, .dormant, .noCredentials: return "link.circle"
case .failed: return "xmark.circle"
}
}
private var stateTint: Color {
switch store.subscriptionLoadState {
case .loaded: return .green
case .terminalFailure, .failed: return .red
case .transientFailure: return .orange
default: return .secondary
}
}
private var stateTitle: String {
switch store.subscriptionLoadState {
case .loaded: return "Connected"
case let .terminalFailure(reason): return reason ?? "Reconnect required"
case .transientFailure: return "Backing off"
case .bootstrapping: return "Connecting…"
case .loading: return "Refreshing…"
case .dormant: return "Ready"
case .notBootstrapped, .noCredentials: return "Not connected"
case .failed: return "Couldn't load plan data"
}
}
private var stateDetail: String {
switch store.subscriptionLoadState {
case .loaded:
if let tier = store.subscription?.tier.displayName {
return "Plan: \(tier)"
}
return "Live quota tracked from Anthropic."
case .terminalFailure: return "Open Claude Code in your terminal and type `/login`, then click Reconnect."
case .transientFailure: return store.subscriptionError ?? "Anthropic rate-limited; auto-retrying."
case .bootstrapping: return "macOS may ask permission to read your credentials."
case .loading: return "Background refresh in progress."
case .dormant: return "Tap Load Quota to fetch live usage from Anthropic."
case .notBootstrapped, .noCredentials: return "Click Connect to read your Claude Code credentials and start tracking quota."
case .failed: return store.subscriptionError ?? ""
}
}
@ViewBuilder
private var actionButton: some View {
switch store.subscriptionLoadState {
case .loaded, .transientFailure, .loading:
Button("Disconnect") { showDisconnectConfirm = true }
.confirmationDialog(
"Disconnect Claude?",
isPresented: $showDisconnectConfirm
) {
Button("Disconnect", role: .destructive) {
store.disconnectSubscription()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("CodeBurn will stop tracking quota and delete its local copy of your Claude credentials. Your Claude Code keychain entry is untouched — Claude Code keeps working.")
}
case .terminalFailure, .noCredentials, .failed:
Button("Reconnect") { Task { await store.bootstrapSubscription() } }
.buttonStyle(.borderedProminent)
case .dormant:
Button("Load Quota") { Task { await store.activateClaudeFromDormant() } }
.buttonStyle(.borderedProminent)
case .notBootstrapped:
Button("Connect") { Task { await store.bootstrapSubscription() } }
.buttonStyle(.borderedProminent)
case .bootstrapping:
ProgressView().controlSize(.small)
}
}
}
// MARK: - Claude config directories
private struct ClaudeConfigDirsSection: View {
@Environment(AppStore.self) private var store
@State private var dirs: [String] = CLIClaudeConfig.load()
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if dirs.isEmpty {
Text("No extra directories — tracking the default `~/.claude`.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} else {
ForEach(Array(dirs.enumerated()), id: \.offset) { index, dir in
HStack(spacing: 8) {
Image(systemName: "folder")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text(dir)
.font(.system(size: 12))
.truncationMode(.middle)
.lineLimit(1)
.help(dir)
Spacer()
Button {
remove(at: index)
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Remove")
}
}
}
Button {
addDirectory()
} label: {
Label("Add Directory…", systemImage: "plus")
}
.controlSize(.small)
}
.padding(.vertical, 2)
}
private func addDirectory() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = true
panel.prompt = "Add"
panel.message = "Choose one or more Claude config directories (each containing a `projects` folder)."
guard panel.runModal() == .OK else { return }
let added = panel.urls.map { $0.path }
var next = dirs
for path in added where !next.contains(path) {
next.append(path)
}
apply(next)
}
private func remove(at index: Int) {
guard dirs.indices.contains(index) else { return }
var next = dirs
next.remove(at: index)
apply(next)
}
/// Persists the new list and kicks a forced refresh so the dashboard
/// reflects the changed aggregation immediately.
private func apply(_ next: [String]) {
dirs = next
CLIClaudeConfig.persist(dirs: next)
Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
}
}
// MARK: - Codex
private struct CodexSettingsTab: View {
@Environment(AppStore.self) private var store
var body: some View {
Form {
Section("Connection") {
CodexConnectionRow()
}
Section {
Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business) is supported — API-key users are billed per request and have a different reporting surface.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {
Text("How it works")
}
}
.formStyle(.grouped)
.padding()
}
}
private struct CodexConnectionRow: View {
@Environment(AppStore.self) private var store
@State private var showDisconnectConfirm = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: stateIcon)
.font(.system(size: 18))
.foregroundStyle(stateTint)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(stateTitle)
.font(.system(size: 12, weight: .semibold))
Text(stateDetail)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
actionButton
}
.padding(.vertical, 4)
}
private var stateIcon: String {
switch store.codexLoadState {
case .loaded: return "checkmark.circle.fill"
case .terminalFailure: return "exclamationmark.triangle.fill"
case .transientFailure: return "clock.arrow.circlepath"
case .bootstrapping, .loading: return "ellipsis.circle"
case .notBootstrapped, .dormant, .noCredentials: return "link.circle"
case .failed: return "xmark.circle"
}
}
private var stateTint: Color {
switch store.codexLoadState {
case .loaded: return .green
case .terminalFailure, .failed: return .red
case .transientFailure: return .orange
default: return .secondary
}
}
private var stateTitle: String {
switch store.codexLoadState {
case .loaded: return "Connected"
case let .terminalFailure(reason): return reason ?? "Reconnect required"
case .transientFailure: return "Backing off"
case .bootstrapping: return "Connecting…"
case .loading: return "Refreshing…"
case .dormant: return "Ready"
case .notBootstrapped, .noCredentials: return "Not connected"
case .failed: return "Couldn't load Codex quota"
}
}
private var stateDetail: String {
switch store.codexLoadState {
case .loaded:
if let plan = store.codexUsage?.plan.displayName {
return "Plan: \(plan)"
}
return "Live quota tracked from chatgpt.com."
case .terminalFailure:
// Be specific about the cause: the message we already surface in
// codexError will say "API-key mode" if that's the situation, so
// the generic "run codex login" hint covers both cases.
if let err = store.codexError, err.lowercased().contains("api-key") {
return "Codex is in API-key mode. Run `codex login` and choose a ChatGPT plan to enable quota tracking."
}
return "Run `codex login` in your terminal to sign in again, then click Reconnect."
case .transientFailure: return store.codexError ?? "ChatGPT rate-limited; auto-retrying."
case .bootstrapping: return "Reading ~/.codex/auth.json."
case .loading: return "Background refresh in progress."
case .dormant: return "Tap Load Quota to fetch live usage from chatgpt.com."
case .notBootstrapped, .noCredentials:
return "Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json."
case .failed: return store.codexError ?? ""
}
}
@ViewBuilder
private var actionButton: some View {
switch store.codexLoadState {
case .loaded, .transientFailure, .loading:
Button("Disconnect") { showDisconnectConfirm = true }
.confirmationDialog(
"Disconnect Codex?",
isPresented: $showDisconnectConfirm
) {
Button("Disconnect", role: .destructive) {
store.disconnectCodex()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("CodeBurn will stop tracking quota and delete its local copy of your Codex credentials. Your ~/.codex/auth.json is untouched — Codex CLI keeps working.")
}
case .terminalFailure, .noCredentials, .failed:
Button("Reconnect") { Task { await store.bootstrapCodex() } }
.buttonStyle(.borderedProminent)
case .dormant:
Button("Load Quota") { Task { await store.activateCodexFromDormant() } }
.buttonStyle(.borderedProminent)
case .notBootstrapped:
Button("Connect") { Task { await store.bootstrapCodex() } }
.buttonStyle(.borderedProminent)
case .bootstrapping:
ProgressView().controlSize(.small)
}
}
}
// MARK: - Devin
private struct DevinSettingsTab: View {
@State private var rateText: String = ""
@State private var statusText: String = ""
private var parsedRate: Double? {
let trimmed = rateText.trimmingCharacters(in: .whitespacesAndNewlines)
guard let value = Double(trimmed), value.isFinite, value > 0 else { return nil }
return value
}
var body: some View {
Form {
Section("ACU Conversion") {
HStack(alignment: .center, spacing: 10) {
Text("USD per ACU")
Spacer()
TextField("", text: $rateText)
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.trailing)
.frame(width: 96)
.accessibilityLabel("USD per ACU")
Text("USD")
.foregroundStyle(.secondary)
.frame(width: 36, alignment: .leading)
}
Button("Save") {
saveRate()
}
.buttonStyle(.borderedProminent)
.disabled(parsedRate == nil)
if !statusText.isEmpty {
Text(statusText)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
Section {
Text("CodeBurn reads Devin ACU usage from local transcripts only after this rate is configured, then multiplies each step by the rate before reporting cost.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {
Text("How it works")
}
}
.formStyle(.grouped)
.padding()
.onAppear {
if let rate = CLIDevinConfig.loadAcuUsdRate() {
rateText = Self.format(rate)
}
}
}
private func saveRate() {
guard let rate = parsedRate else { return }
CLIDevinConfig.persistAcuUsdRate(rate)
rateText = Self.format(rate)
statusText = "Saved. Refresh CodeBurn to recalculate Devin cost."
}
private static func format(_ value: Double) -> String {
let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 6
return formatter.string(from: NSNumber(value: value)) ?? String(value)
}
}
// MARK: - About
private struct AboutSettingsTab: View {
private let appVersion: String = AppVersion.normalizedBundleShortVersion
private let buildVersion: String = AppVersion.normalizedBundleBuildVersion
var body: some View {
VStack(spacing: 14) {
Image(systemName: "flame.fill")
.font(.system(size: 40))
.foregroundStyle(Theme.brandAccent)
Text("CodeBurn")
.font(.system(size: 18, weight: .semibold))
Text("AI Coding Cost Tracker")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text("Version \(appVersion) (\(buildVersion))")
.font(.codeMono(size: 11))
.foregroundStyle(.secondary)
Link(destination: URL(string: "https://github.com/getagentseal/codeburn")!) {
Label("Star on GitHub", systemImage: "star.fill")
.font(.system(size: 12, weight: .medium))
}
.buttonStyle(.borderedProminent)
.tint(Theme.brandAccent)
HStack(spacing: 10) {
Link("GitHub", destination: URL(string: "https://github.com/getagentseal/codeburn")!)
Link("Issues", destination: URL(string: "https://github.com/getagentseal/codeburn/issues")!)
Link("Sponsor", destination: URL(string: "https://github.com/sponsors/iamtoruk")!)
}
.font(.system(size: 12))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
@@ -0,0 +1,99 @@
import SwiftUI
struct SparklineView: View {
let points: [Double]
var body: some View {
GeometryReader { geo in
let cgPoints = makePoints(in: geo.size)
let smooth = smoothPath(cgPoints)
ZStack {
// Gradient fill under the curve
let fill = closedPath(smooth, width: geo.size.width, height: geo.size.height)
fill.fill(
LinearGradient(
colors: [Theme.brandAccent.opacity(0.25), .clear],
startPoint: .top,
endPoint: .bottom
)
)
// Smooth accent stroke
smooth.stroke(
Theme.brandAccent.opacity(0.85),
style: StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round)
)
// Highlighted current-day point
if let last = cgPoints.last {
Circle()
.fill(Theme.brandAccent)
.frame(width: 6, height: 6)
.overlay(
Circle()
.stroke(Color(NSColor.windowBackgroundColor).opacity(0.9), lineWidth: 1.3)
)
.position(last)
}
}
}
}
// MARK: - Geometry
private func makePoints(in size: CGSize) -> [CGPoint] {
guard !points.isEmpty else { return [] }
let w = size.width
let h = size.height
let maxV = points.max() ?? 1
let minV = points.min() ?? 0
let range = max(maxV - minV, 1)
let count = max(points.count - 1, 1)
let topPad: CGFloat = 5
let bottomPad: CGFloat = 5
let usable = max(h - topPad - bottomPad, 1)
return points.enumerated().map { idx, v in
CGPoint(
x: w * CGFloat(idx) / CGFloat(count),
y: h - bottomPad - usable * CGFloat(v - minV) / CGFloat(range)
)
}
}
/// Catmull-Rom cubic bezier. Standard smooth interpolation, no overshoot.
private func smoothPath(_ pts: [CGPoint]) -> Path {
var path = Path()
guard pts.count >= 2 else { return path }
path.move(to: pts[0])
let tension: CGFloat = 0.5
for i in 0..<(pts.count - 1) {
let p0 = i > 0 ? pts[i - 1] : pts[i]
let p1 = pts[i]
let p2 = pts[i + 1]
let p3 = i + 2 < pts.count ? pts[i + 2] : p2
let cp1 = CGPoint(
x: p1.x + (p2.x - p0.x) * tension / 3,
y: p1.y + (p2.y - p0.y) * tension / 3
)
let cp2 = CGPoint(
x: p2.x - (p3.x - p1.x) * tension / 3,
y: p2.y - (p3.y - p1.y) * tension / 3
)
path.addCurve(to: p2, control1: cp1, control2: cp2)
}
return path
}
/// Close the path along the bottom to form a fill region.
private func closedPath(_ line: Path, width: CGFloat, height: CGFloat) -> Path {
var p = line
p.addLine(to: CGPoint(x: width, y: height))
p.addLine(to: CGPoint(x: 0, y: height))
p.closeSubpath()
return p
}
}
@@ -0,0 +1,137 @@
import SwiftUI
struct ToolingSection: View {
@Environment(AppStore.self) private var store
@State private var isExpanded: Bool = false
private var skillsAndAgents: [CostRowData] {
let current = store.payload.current
var merged: [String: (uses: Int, cost: Double)] = [:]
for s in current.skills {
let e = merged[s.name, default: (0, 0)]
merged[s.name] = (e.uses + s.turns, e.cost + s.cost)
}
for a in current.subagents {
let e = merged[a.name, default: (0, 0)]
merged[a.name] = (e.uses + a.calls, e.cost + a.cost)
}
return merged
.map { CostRowData(name: $0.key, uses: $0.value.uses, cost: $0.value.cost) }
.sorted { $0.cost > $1.cost }
}
var body: some View {
let current = store.payload.current
let combined = skillsAndAgents
let hasAny = !current.tools.isEmpty || !combined.isEmpty || !current.mcpServers.isEmpty
if hasAny {
CollapsibleSection(caption: "Tooling", isExpanded: $isExpanded) {
VStack(alignment: .leading, spacing: 12) {
if !current.tools.isEmpty {
ToolingSubsection(title: "Tools") {
let maxCalls = current.tools.map(\.calls).max() ?? 1
ForEach(current.tools, id: \.name) { t in
CallsRow(name: t.name, calls: t.calls, maxCalls: maxCalls)
}
}
}
if !combined.isEmpty {
ToolingSubsection(title: "Skills & Agents") {
let maxCost = max(combined.map(\.cost).max() ?? 0.01, 0.01)
ForEach(combined, id: \.name) { d in
CostRow(name: d.name, cost: d.cost, count: d.uses, countLabel: "uses", maxCost: maxCost)
}
}
}
if !current.mcpServers.isEmpty {
ToolingSubsection(title: "MCP Servers") {
let maxCalls = current.mcpServers.map(\.calls).max() ?? 1
ForEach(current.mcpServers, id: \.name) { m in
CallsRow(name: m.name, calls: m.calls, maxCalls: maxCalls)
}
}
}
}
}
}
}
}
private struct CostRowData {
let name: String
let uses: Int
let cost: Double
}
private struct ToolingSubsection<Content: View>: View {
let title: String
let content: Content
init(title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.system(size: 10.5, weight: .semibold))
.foregroundStyle(.tertiary)
.textCase(.uppercase)
.tracking(0.5)
content
}
}
}
private struct CallsRow: View {
let name: String
let calls: Int
let maxCalls: Int
var body: some View {
HStack(spacing: 8) {
FixedBar(fraction: Double(calls) / Double(max(maxCalls, 1)))
.frame(width: 40, height: 5)
Text(name)
.font(.system(size: 12, weight: .medium))
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Text("\(calls)")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 36, alignment: .trailing)
}
.padding(.vertical, 1)
}
}
private struct CostRow: View {
let name: String
let cost: Double
let count: Int
let countLabel: String
let maxCost: Double
var body: some View {
HStack(spacing: 8) {
FixedBar(fraction: cost / max(maxCost, 0.01))
.frame(width: 40, height: 5)
Text(name)
.font(.system(size: 12, weight: .medium))
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Text("\(count)")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 30, alignment: .trailing)
Text(cost.asCompactCurrency())
.font(.codeMono(size: 11, weight: .medium))
.tracking(-0.2)
.frame(minWidth: 46, alignment: .trailing)
}
.padding(.vertical, 1)
}
}
@@ -0,0 +1,389 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
private func combinedUsage(cost: Double = 12.5) -> CombinedUsage {
CombinedUsage(
perDevice: [
CombinedDeviceUsage(
id: "local",
name: "MacBook",
local: true,
error: nil,
cost: cost,
calls: 3,
sessions: 2,
inputTokens: 100,
outputTokens: 50,
cacheCreateTokens: 10,
cacheReadTokens: 20,
totalTokens: 180
)
],
combined: CombinedUsageTotals(
cost: cost,
calls: 3,
sessions: 2,
inputTokens: 100,
outputTokens: 50,
cacheCreateTokens: 10,
cacheReadTokens: 20,
totalTokens: 180,
deviceCount: 1,
reachableCount: 1
)
)
}
private func claudeConfigSelector(selectedId: String? = nil) -> ClaudeConfigSelector {
ClaudeConfigSelector(
selectedId: selectedId,
options: [
ClaudeConfigOption(id: "claude-config:work", label: "claude-work", path: "/tmp/claude-work"),
ClaudeConfigOption(id: "claude-config:personal", label: "claude-personal", path: "/tmp/claude-personal")
]
)
}
private func menubarPayload(cost: Double,
combined: CombinedUsage? = nil,
claudeConfigs: ClaudeConfigSelector? = nil) -> MenubarPayload {
MenubarPayload(
generated: "test",
current: CurrentBlock(
label: "Today",
cost: cost,
calls: 1,
sessions: 1,
oneShotRate: nil,
inputTokens: 1,
outputTokens: 1,
cacheHitPercent: 0,
codexCredits: nil,
topActivities: [],
topModels: [],
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
providers: ["claude": cost],
topProjects: [],
modelEfficiency: [],
topSessions: [],
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []),
tools: [],
skills: [],
subagents: [],
mcpServers: []
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: []),
combined: combined,
claudeConfigs: claudeConfigs
)
}
@Suite("AppStore refresh recovery")
@MainActor
struct AppStoreRefreshRecoveryTests {
@Test("stale visible payload triggers hard recovery without clearing cache")
func stalePayloadTriggersHardRecoveryWithoutClearingCache() {
let store = AppStore()
store.setCachedPayloadForTesting(
menubarPayload(cost: 92.33),
period: .today,
provider: .all,
fetchedAt: Date().addingTimeInterval(-180)
)
#expect(store.todayPayload?.current.cost == 92.33)
#expect(store.needsInteractivePayloadRefresh)
#expect(store.needsStatusPayloadRefresh)
#expect(store.hasStaleInteractivePayload)
#expect(store.shouldResetInteractiveRefreshPipeline)
store.resetRefreshState(clearCache: false)
#expect(store.todayPayload?.current.cost == 92.33)
}
@Test("fresh visible payload does not trigger hard recovery")
func freshPayloadDoesNotTriggerHardRecovery() {
let store = AppStore()
store.setCachedPayloadForTesting(
menubarPayload(cost: 164.06),
period: .today,
provider: .all,
fetchedAt: Date()
)
#expect(!store.needsInteractivePayloadRefresh)
#expect(!store.needsStatusPayloadRefresh)
#expect(!store.hasStaleInteractivePayload)
#expect(!store.shouldResetInteractiveRefreshPipeline)
}
@Test("payload cache partitions local and combined scope")
func payloadCachePartitionsByScope() {
let store = AppStore()
store.setCachedPayloadForTesting(
menubarPayload(cost: 10),
scope: .local,
period: .today,
provider: .all,
fetchedAt: Date()
)
store.setCachedPayloadForTesting(
menubarPayload(cost: 99, combined: combinedUsage(cost: 42)),
scope: .combined,
period: .today,
provider: .all,
fetchedAt: Date()
)
#expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all)?.current.cost == 10)
#expect(store.cachedPayloadForTesting(scope: .combined, period: .today, provider: .all)?.current.cost == 99)
store.selectedScope = .combined
#expect(store.payload.current.cost == 10)
#expect(store.payload.combined?.combined.cost == 42)
}
@Test("multi-day combined selection uses local cache path")
func multiDayCombinedSelectionUsesLocalCachePath() {
let store = AppStore()
let days: Set<String> = ["2026-06-01", "2026-06-02"]
store.selectedScope = .combined
store.selectedDays = days
store.setCachedPayloadForTesting(
menubarPayload(cost: 18),
scope: .local,
period: .today,
provider: .all,
days: days,
fetchedAt: Date()
)
store.setCachedPayloadForTesting(
menubarPayload(cost: 99, combined: combinedUsage(cost: 44)),
scope: .combined,
period: .today,
provider: .all,
days: days,
fetchedAt: Date()
)
#expect(store.activeScope == .local)
#expect(store.payload.current.cost == 18)
#expect(store.payload.combined == nil)
}
@Test("combined failure state does not invalidate local badge payload")
func combinedFailureDoesNotInvalidateLocalBadgePayload() {
let store = AppStore()
store.setCachedPayloadForTesting(
menubarPayload(cost: 31),
scope: .local,
period: .today,
provider: .all,
fetchedAt: Date()
)
store.selectedScope = .combined
store.setLastErrorForTesting(
"timeout",
scope: .combined,
period: .today,
provider: .all
)
#expect(store.lastError == "timeout")
#expect(store.menubarPayload?.current.cost == 31)
#expect(!store.needsStatusPayloadRefresh)
#expect(store.payload.current.cost == 31)
#expect(store.payload.combined == nil)
}
@Test("switching to combined resets selected provider to all")
func switchingToCombinedResetsSelectedProviderToAll() {
let store = AppStore()
store.suppressRefreshesForTesting()
store.selectedScope = .local
store.selectedProvider = .claude
store.switchTo(scope: .combined)
#expect(store.selectedScope == .combined)
#expect(store.selectedProvider == .all)
}
@Test("selected Claude config partitions payload cache")
func selectedClaudeConfigPartitionsPayloadCache() {
let store = AppStore()
store.setCachedPayloadForTesting(
menubarPayload(cost: 10, claudeConfigs: claudeConfigSelector()),
scope: .local,
period: .today,
provider: .all,
fetchedAt: Date()
)
store.setCachedPayloadForTesting(
menubarPayload(cost: 4, claudeConfigs: claudeConfigSelector(selectedId: "claude-config:work")),
scope: .local,
period: .today,
provider: .all,
claudeConfigSourceId: "claude-config:work",
fetchedAt: Date()
)
#expect(store.payload.current.cost == 10)
store.selectedClaudeConfigSourceId = "claude-config:work"
#expect(store.payload.current.cost == 4)
#expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all)?.current.cost == 10)
#expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all, claudeConfigSourceId: "claude-config:work")?.current.cost == 4)
}
@Test("Claude config selector is hidden until multiple configs are available")
func claudeConfigSelectorVisibilityRequiresMultipleConfigs() {
let store = AppStore()
store.setCachedPayloadForTesting(
menubarPayload(cost: 1),
scope: .local,
period: .today,
provider: .all,
fetchedAt: Date()
)
#expect(!store.shouldShowClaudeConfigSelector)
store.setCachedPayloadForTesting(
menubarPayload(cost: 2, claudeConfigs: claudeConfigSelector()),
scope: .local,
period: .today,
provider: .all,
fetchedAt: Date()
)
#expect(store.shouldShowClaudeConfigSelector)
#expect(store.claudeConfigOptions.map(\.label) == ["claude-work", "claude-personal"])
}
@Test("selecting Claude config resets provider and combined scope")
func selectingClaudeConfigResetsProviderAndCombinedScope() {
let store = AppStore()
store.suppressRefreshesForTesting()
store.selectedScope = .combined
store.selectedProvider = .codex
store.switchTo(claudeConfigSourceId: "claude-config:work")
#expect(store.selectedClaudeConfigSourceId == "claude-config:work")
#expect(store.selectedScope == .local)
#expect(store.selectedProvider == .all)
}
@Test("daily budget warning is suppressed for combined scope")
func dailyBudgetWarningIsSuppressedForCombinedScope() {
let defaults = UserDefaults.standard
let previousDisplayMetric = defaults.object(forKey: "CodeBurnDisplayMetric")
let previousDailyBudget = defaults.object(forKey: "CodeBurnDailyBudget")
defer {
if let previousDisplayMetric {
defaults.set(previousDisplayMetric, forKey: "CodeBurnDisplayMetric")
} else {
defaults.removeObject(forKey: "CodeBurnDisplayMetric")
}
if let previousDailyBudget {
defaults.set(previousDailyBudget, forKey: "CodeBurnDailyBudget")
} else {
defaults.removeObject(forKey: "CodeBurnDailyBudget")
}
}
let store = AppStore()
store.selectedScope = .local
store.selectedDays = []
store.displayMetric = .cost
store.dailyBudget = 10
store.setCachedPayloadForTesting(
menubarPayload(cost: 12.5),
scope: .local,
period: .today,
provider: .all,
fetchedAt: Date()
)
#expect(store.isOverDailyBudget)
#expect(store.shouldShowDailyBudgetWarning)
store.selectedScope = .combined
#expect(store.isOverDailyBudget)
#expect(!store.shouldShowDailyBudgetWarning)
}
@Test("missing today status payload needs status refresh")
func missingTodayStatusPayloadNeedsStatusRefresh() {
let store = AppStore()
#expect(store.todayPayload == nil)
#expect(store.needsStatusPayloadRefresh)
}
@Test("missing unattempted payload triggers hard recovery")
func missingUnattemptedPayloadTriggersHardRecovery() {
let store = AppStore()
#expect(!store.hasCachedData)
#expect(!store.hasAttemptedCurrentKeyLoad)
#expect(store.needsInteractivePayloadRefresh)
#expect(store.hasMissingInteractivePayloadWithoutAttempt)
#expect(store.shouldResetInteractiveRefreshPipeline)
}
@Test("orphaned stale in-flight entry does not block stuck-loading recovery")
func staleInFlightDoesNotBlockRecovery() {
let store = AppStore()
// A quiet refresh torn down across sleep/wake can leave an in-flight
// entry behind for the current key with no cache and no active loading
// counter, far older than the watchdog window. Recovery must clear it
// and proceed instead of bailing on the in-flight guard forever.
store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date().addingTimeInterval(-3600))
#expect(store.isInFlightForTesting(period: .today, provider: .all))
let canRecover = store.prepareStuckLoadingRecovery()
#expect(canRecover)
#expect(!store.isInFlightForTesting(period: .today, provider: .all))
}
@Test("healthy in-flight fetch is not killed by recovery")
func healthyInFlightFetchSurvivesRecovery() {
let store = AppStore()
store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date())
let canRecover = store.prepareStuckLoadingRecovery()
#expect(!canRecover)
#expect(store.isInFlightForTesting(period: .today, provider: .all))
}
@Test("prepareStuckLoadingRecovery clears stale loading bookkeeping for the current key")
func popoverRecoveryClearsStuckLoading() {
let store = AppStore()
// Seed an orphaned in-flight entry older than the 60s watchdog so the
// stale-clear path runs, mimicking a fetch torn down across sleep/wake.
store.seedInFlightForTesting(
period: .today,
provider: .all,
insertedAt: Date().addingTimeInterval(-120)
)
#expect(store.isInFlightForTesting(period: .today, provider: .all))
let willFetch = store.prepareStuckLoadingRecovery()
#expect(willFetch)
#expect(!store.isInFlightForTesting(period: .today, provider: .all))
}
}
@@ -0,0 +1,19 @@
import Testing
@testable import CodeBurnMenubar
@Suite("AppVersion")
struct AppVersionTests {
@Test("display avoids duplicate v prefix")
func displayAvoidsDuplicatePrefix() {
#expect(AppVersion.display("0.9.8") == "v0.9.8")
#expect(AppVersion.display("v0.9.8") == "v0.9.8")
#expect(AppVersion.display("mac-v0.9.8") == "v0.9.8")
}
@Test("bundle metadata stores unprefixed semver")
func normalizeBundleVersion() {
#expect(AppVersion.normalize("v0.9.8") == "0.9.8")
#expect(AppVersion.normalize("mac-v0.9.8") == "0.9.8")
#expect(AppVersion.normalize("dev") == "dev")
}
}
@@ -0,0 +1,97 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("CLI Devin config", .serialized)
struct CLIDevinConfigTests {
private func withTemporaryStore(_ body: (URL, CodeburnCLIConfigStore) throws -> Void) throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("codeburn-devin-config-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: root)
}
try body(root, CodeburnCLIConfigStore(homeDirectory: root.path))
}
private func configURL(in home: URL) -> URL {
home
.appendingPathComponent(".config", isDirectory: true)
.appendingPathComponent("codeburn", isDirectory: true)
.appendingPathComponent("config.json")
}
private func writeConfig(_ object: [String: Any], in home: URL) throws {
let url = configURL(in: home)
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
try data.write(to: url)
}
private func readConfig(in home: URL) throws -> [String: Any] {
let data = try Data(contentsOf: configURL(in: home))
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
@Test("missing config has no ACU rate")
func missingConfigHasNoRate() throws {
try withTemporaryStore { _, store in
#expect(store.loadDevinAcuUsdRate() == nil)
}
}
@Test("persists and loads ACU rate")
func persistsAndLoadsRate() throws {
try withTemporaryStore { _, store in
store.persistDevinAcuUsdRate(2.25)
#expect(store.loadDevinAcuUsdRate() == 2.25)
}
}
@Test("preserves existing config while adding Devin rate")
func preservesExistingConfig() throws {
try withTemporaryStore { home, store in
try writeConfig([
"currency": [
"code": "EUR",
"symbol": "\u{20AC}"
]
], in: home)
store.persistDevinAcuUsdRate(3.5)
let json = try readConfig(in: home)
let currency = try #require(json["currency"] as? [String: Any])
let devin = try #require(json["devin"] as? [String: Any])
#expect(currency["code"] as? String == "EUR")
#expect(devin["acuUsdRate"] as? Double == 3.5)
}
}
@Test("ignores invalid rates")
func ignoresInvalidRates() throws {
try withTemporaryStore { _, store in
store.persistDevinAcuUsdRate(1.75)
store.persistDevinAcuUsdRate(0)
store.persistDevinAcuUsdRate(-2)
store.persistDevinAcuUsdRate(.infinity)
#expect(store.loadDevinAcuUsdRate() == 1.75)
}
}
@Test("loads only positive finite numeric rates")
func loadsOnlyPositiveFiniteNumericRates() throws {
try withTemporaryStore { home, store in
try writeConfig(["devin": ["acuUsdRate": 0]], in: home)
#expect(store.loadDevinAcuUsdRate() == nil)
try writeConfig(["devin": ["acuUsdRate": "2.25"]], in: home)
#expect(store.loadDevinAcuUsdRate() == nil)
}
}
}
@@ -0,0 +1,158 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
private let now = Date(timeIntervalSince1970: 1_734_000_000)
private func snap(_ percent: Double, _ tokens: Double, ageDays: Double = 0) -> CapacitySnapshot {
CapacitySnapshot(
percent: percent,
effectiveTokens: tokens,
capturedAt: now.addingTimeInterval(-ageDays * 86400)
)
}
@Suite("CapacityEstimator -- gating")
struct CapacityEstimatorGatingTests {
@Test("returns nil with no snapshots")
func emptyReturnsNil() {
#expect(CapacityEstimator.estimate([], asOf: now) == nil)
}
@Test("returns nil with fewer than 5 snapshots")
func tooFewReturnsNil() {
let snaps = (1...4).map { snap(Double($0 * 10), Double($0) * 100_000) }
#expect(CapacityEstimator.estimate(snaps, asOf: now) == nil)
}
@Test("returns nil when percent range is below 15 points")
func tooNarrowReturnsNil() {
let snaps = [
snap(40, 4_000_000),
snap(42, 4_200_000),
snap(44, 4_400_000),
snap(46, 4_600_000),
snap(48, 4_800_000),
snap(50, 5_000_000),
]
#expect(CapacityEstimator.estimate(snaps, asOf: now) == nil)
}
}
@Suite("CapacityEstimator -- recovery")
struct CapacityEstimatorRecoveryTests {
@Test("recovers capacity from 10 noise-free snapshots within 0.5%")
func recoverFromCleanData() {
let trueCapacity: Double = 10_000_000
let percents = [5.0, 12, 20, 28, 35, 47, 55, 68, 80, 92]
let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
let est = CapacityEstimator.estimate(snaps, asOf: now)
#expect(est != nil)
#expect(est!.capacity > trueCapacity * 0.995)
#expect(est!.capacity < trueCapacity * 1.005)
// 10 perfect samples is below the solid sample threshold (15) but easily medium.
#expect(est!.confidence == .medium || est!.confidence == .solid)
}
@Test("recovers capacity within 5% from 30 noisy snapshots")
func recoverFromNoisyData() {
let trueCapacity: Double = 8_000_000
var rng = LinearCongruentialGenerator(seed: 42)
let snaps: [CapacitySnapshot] = (0..<30).map { i in
let p = 5.0 + Double(i) * 3.0 // 5..92, spanning enough
let noise = (rng.nextDouble() - 0.5) * 0.10 // ±5%
let tokens = (p / 100) * trueCapacity * (1 + noise)
return snap(p, tokens)
}
let est = CapacityEstimator.estimate(snaps, asOf: now)
#expect(est != nil)
let ratio = est!.capacity / trueCapacity
#expect(ratio > 0.95 && ratio < 1.05)
#expect(est!.confidence == .solid || est!.confidence == .medium)
}
}
@Suite("CapacityEstimator -- confidence tiers")
struct CapacityEstimatorConfidenceTests {
@Test("six clean snapshots span sufficient range -> at least medium")
func sixCleanSnapshotsMedium() {
let trueCapacity: Double = 5_000_000
let percents = [5.0, 18, 32, 51, 70, 88]
let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
let est = CapacityEstimator.estimate(snaps, asOf: now)
#expect(est != nil)
#expect(est!.confidence == .medium || est!.confidence == .solid)
}
@Test("noisy small-sample data falls to low confidence")
func noisySmallSampleLow() {
let trueCapacity: Double = 5_000_000
var rng = LinearCongruentialGenerator(seed: 7)
let percents = [5.0, 22, 40, 60, 80, 95]
let snaps: [CapacitySnapshot] = percents.map { p in
let noise = (rng.nextDouble() - 0.5) * 1.6 // ±80% noise -> drops R^2 below medium gate
return snap(p, p / 100 * trueCapacity * (1 + noise))
}
let est = CapacityEstimator.estimate(snaps, asOf: now)
#expect(est != nil)
#expect(est!.confidence == .low)
}
}
@Suite("CapacityEstimator -- recency weighting")
struct CapacityEstimatorRecencyTests {
@Test("recent snapshots dominate over old ones with different capacity")
func recencyShiftsEstimate() {
// Old data: capacity = 5M (45 days ago)
// New data: capacity = 10M (today)
// With 30-day half-life, recent data should win.
let oldSnaps = (0..<10).map { i -> CapacitySnapshot in
let p = 10.0 + Double(i) * 8
return snap(p, p / 100 * 5_000_000, ageDays: 45)
}
let newSnaps = (0..<10).map { i -> CapacitySnapshot in
let p = 10.0 + Double(i) * 8
return snap(p, p / 100 * 10_000_000, ageDays: 1)
}
let est = CapacityEstimator.estimate(oldSnaps + newSnaps, asOf: now)
#expect(est != nil)
// Recent capacity is 10M; estimate should be closer to 10M than 5M.
#expect(est!.capacity > 7_500_000)
}
}
@Suite("CapacityEstimator -- non-linearity")
struct CapacityEstimatorNonLinearityTests {
@Test("flags non-linearity when residuals show systematic sign pattern")
func detectsKneePattern() {
// Data follows a knee: linear up to 60%, then flatter (Anthropic capping).
let snaps: [CapacitySnapshot] = (0..<20).map { i in
let p = 5.0 + Double(i) * 5
let tokens: Double = p < 60 ? p / 100 * 8_000_000 : 0.6 * 8_000_000 + (p - 60) / 100 * 4_000_000
return snap(p, tokens)
}
let est = CapacityEstimator.estimate(snaps, asOf: now)
#expect(est != nil)
#expect(est!.nonLinearityWarning == true)
}
@Test("does not flag clean linear data")
func cleanLinearNoFlag() {
let trueCapacity: Double = 6_000_000
let percents = stride(from: 5.0, to: 95.0, by: 5.0).map { $0 }
let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
let est = CapacityEstimator.estimate(snaps, asOf: now)
#expect(est != nil)
#expect(est!.nonLinearityWarning == false)
}
}
// Lightweight deterministic RNG for reproducible noise in tests.
struct LinearCongruentialGenerator {
private var state: UInt64
init(seed: UInt64) { self.state = seed }
mutating func nextDouble() -> Double {
state = state &* 6364136223846793005 &+ 1442695040888963407
return Double(state >> 11) / Double(1 << 53)
}
}
@@ -0,0 +1,77 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("Claude usage response parsing")
struct ClaudeSubscriptionParsingTests {
// Shape captured from the live oauth/usage endpoint (2026-07): named
// windows plus a `limits` array carrying model-scoped weekly buckets.
private let liveShape = """
{
"five_hour": { "utilization": 13.0, "resets_at": "2026-07-02T22:09:59.599633+00:00", "limit_dollars": null, "used_dollars": null, "remaining_dollars": null },
"seven_day": { "utilization": 63.0, "resets_at": "2026-07-03T06:59:59.599658+00:00", "limit_dollars": null, "used_dollars": null, "remaining_dollars": null },
"seven_day_oauth_apps": null,
"seven_day_opus": null,
"seven_day_sonnet": null,
"extra_usage": { "is_enabled": false },
"limits": [
{ "kind": "session", "group": "session", "percent": 13, "severity": "normal", "resets_at": "2026-07-02T22:09:59.456907+00:00", "scope": null, "is_active": false },
{ "kind": "weekly_all", "group": "weekly", "percent": 63, "severity": "normal", "resets_at": "2026-07-03T06:59:59.456926+00:00", "scope": null, "is_active": false },
{ "kind": "weekly_scoped", "group": "weekly", "percent": 94, "severity": "critical", "resets_at": "2026-07-03T06:59:59.457220+00:00", "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, "is_active": true }
]
}
"""
@Test("model-scoped weekly bucket surfaces with its display name")
func scopedWeeklyParsed() throws {
let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: "max_20x")
#expect(usage.scopedWeekly.count == 1)
let fable = try #require(usage.scopedWeekly.first)
#expect(fable.label == "Fable")
#expect(fable.percent == 94)
#expect(fable.resetsAt != nil)
}
@Test("named windows still map alongside limits")
func namedWindowsUnaffected() throws {
let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: "max_20x")
#expect(usage.fiveHourPercent == 13.0)
#expect(usage.sevenDayPercent == 63.0)
#expect(usage.sevenDayOpusPercent == nil)
#expect(usage.sevenDaySonnetPercent == nil)
#expect(usage.tier == .max20x)
}
@Test("session and weekly_all limits are not duplicated as scoped rows")
func unscopedKindsSkipped() throws {
let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: nil)
#expect(!usage.scopedWeekly.contains { $0.percent == 13 || $0.percent == 63 })
}
@Test("response without a limits array parses with no scoped windows")
func missingLimitsIsBackCompat() throws {
let old = """
{
"five_hour": { "utilization": 40.0, "resets_at": "2026-07-02T22:00:00+00:00" },
"seven_day": { "utilization": 12.5, "resets_at": "2026-07-03T07:00:00+00:00" }
}
"""
let usage = try ClaudeSubscriptionService.parseUsage(Data(old.utf8), rawTier: "pro")
#expect(usage.scopedWeekly.isEmpty)
#expect(usage.fiveHourPercent == 40.0)
#expect(usage.tier == .pro)
}
@Test("weekly_scoped without a model display name is skipped")
func scopedWithoutNameSkipped() throws {
let body = """
{
"limits": [
{ "kind": "weekly_scoped", "percent": 50, "resets_at": "2026-07-03T07:00:00+00:00", "scope": { "model": null } }
]
}
"""
let usage = try ClaudeSubscriptionService.parseUsage(Data(body.utf8), rawTier: nil)
#expect(usage.scopedWeekly.isEmpty)
}
}
@@ -0,0 +1,36 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("Codex terminal failure classification")
struct CodexTerminalFailureTests {
@Test("expired refresh token (4xx) is terminal")
func expiredRefreshTokenIsTerminal() {
let err = CodexCredentialStore.StoreError.refreshHTTPError(400, "invalid_grant: refresh_token_expired")
#expect(err.isTerminal)
}
@Test("missing refresh token is terminal")
func missingRefreshTokenIsTerminal() {
#expect(CodexCredentialStore.StoreError.noRefreshToken.isTerminal)
}
@Test("a 5xx refresh error is not terminal")
func serverErrorIsNotTerminal() {
let err = CodexCredentialStore.StoreError.refreshHTTPError(503, "service unavailable")
#expect(!err.isTerminal)
}
@Test("a network error is not terminal")
func networkErrorIsNotTerminal() {
let err = CodexCredentialStore.StoreError.refreshNetworkError(URLError(.timedOut))
#expect(!err.isTerminal)
}
@Test("FetchError wrapping a terminal credential error is terminal")
func fetchErrorPropagatesTerminal() {
let store = CodexCredentialStore.StoreError.refreshHTTPError(401, "invalid_grant")
let fetch = CodexSubscriptionService.FetchError.credential(store)
#expect(fetch.isTerminal)
}
}
@@ -0,0 +1,117 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
private let heatmapNow: Date = {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
return calendar.date(from: DateComponents(year: 2026, month: 6, day: 3, hour: 12))!
}()
private let heatmapCalendar: Calendar = {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
return calendar
}()
private let heatmapFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(secondsFromGMT: 0)!
return formatter
}()
private func historyEntry(
_ date: String,
cost: Double,
calls: Int = 1,
inputTokens: Int = 100,
outputTokens: Int = 20
) -> DailyHistoryEntry {
DailyHistoryEntry(
date: date,
cost: cost,
savingsUSD: 0,
calls: calls,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: []
)
}
@Suite("Contribution heatmap")
struct ContributionHeatmapTests {
@Test("builds Monday-start weeks ending with the current week")
func buildsMondayStartWeeks() {
let weeks = buildContributionWeeks(
from: [historyEntry("2026-06-03", cost: 10)],
weekCount: 2,
now: heatmapNow,
calendar: heatmapCalendar,
formatter: heatmapFormatter
)
#expect(weeks.map(\.startDate) == ["2026-05-25", "2026-06-01"])
#expect(weeks.allSatisfy { $0.days.count == 7 })
#expect(weeks[1].days[0].date == "2026-06-01")
#expect(weeks[1].days[2].date == "2026-06-03")
#expect(weeks[1].days[2].isToday)
}
@Test("marks future cells after today")
func marksFutureCells() {
let weeks = buildContributionWeeks(
from: [
historyEntry("2026-06-03", cost: 10),
historyEntry("2026-06-04", cost: 99),
],
weekCount: 1,
now: heatmapNow,
calendar: heatmapCalendar,
formatter: heatmapFormatter
)
let currentWeek = weeks[0].days
#expect(currentWeek[2].date == "2026-06-03")
#expect(currentWeek[2].cost == 10)
#expect(!currentWeek[2].isFuture)
#expect(currentWeek[3].date == "2026-06-04")
#expect(currentWeek[3].cost == 0)
#expect(currentWeek[3].isFuture)
}
@Test("maps costs to four nonzero intensity levels")
func mapsIntensityLevels() {
#expect(contributionLevel(value: 0, maxValue: 100) == 0)
#expect(contributionLevel(value: 10, maxValue: 100) == 1)
#expect(contributionLevel(value: 25, maxValue: 100) == 2)
#expect(contributionLevel(value: 50, maxValue: 100) == 3)
#expect(contributionLevel(value: 75, maxValue: 100) == 4)
#expect(contributionLevel(value: 100, maxValue: 100) == 4)
}
@MainActor
@Test("computes total, active days, average, and current streak")
func computesStats() {
let weeks = buildContributionWeeks(
from: [
historyEntry("2026-06-01", cost: 3),
historyEntry("2026-06-02", cost: 0),
historyEntry("2026-06-03", cost: 9),
],
weekCount: 1,
now: heatmapNow,
calendar: heatmapCalendar,
formatter: heatmapFormatter
)
let stats = computeContributionStats(weeks: weeks)
#expect(stats.total == 12)
#expect(stats.activeDays == 2)
#expect(stats.avgActive == 6)
#expect(stats.currentStreak == 1)
#expect(stats.peakLabel.contains("Jun 3"))
}
}
@@ -0,0 +1,224 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("DataClient process")
struct DataClientProcessTests {
@Test("status argv local omits scope")
func statusSubcommandLocalOmitsScope() {
let args = DataClient.statusSubcommand(
period: .today,
provider: .claude,
includeOptimize: false,
scope: .local
)
#expect(!args.contains("--scope"))
#expect(value(after: "--provider", in: args) == "claude")
#expect(args.contains("--no-optimize"))
}
@Test("status argv combined adds scope and forces provider all")
func statusSubcommandCombinedAddsScopeAndForcesAllProvider() {
let args = DataClient.statusSubcommand(
period: .today,
provider: .codex,
includeOptimize: true,
scope: .combined
)
#expect(value(after: "--scope", in: args) == "combined")
#expect(value(after: "--provider", in: args) == "all")
#expect(!args.contains("--no-optimize"))
}
@Test("status argv combined multi-day coerces to local")
func statusSubcommandCombinedMultiDayCoercesToLocal() {
let args = DataClient.statusSubcommand(
period: .today,
days: ["2026-06-01", "2026-06-02"],
provider: .codex,
includeOptimize: false,
scope: .combined
)
#expect(!args.contains("--scope"))
#expect(value(after: "--provider", in: args) == "codex")
#expect(value(after: "--days", in: args) == "2026-06-01,2026-06-02")
}
@Test("status argv local includes Claude config source")
func statusSubcommandLocalIncludesClaudeConfigSource() {
let args = DataClient.statusSubcommand(
period: .today,
provider: .all,
includeOptimize: false,
scope: .local,
claudeConfigSourceId: "claude-config:work"
)
#expect(value(after: "--claude-config-source", in: args) == "claude-config:work")
}
@Test("status argv combined omits Claude config source")
func statusSubcommandCombinedOmitsClaudeConfigSource() {
let args = DataClient.statusSubcommand(
period: .today,
provider: .all,
includeOptimize: false,
scope: .combined,
claudeConfigSourceId: "claude-config:work"
)
#expect(value(after: "--scope", in: args) == "combined")
#expect(!args.contains("--claude-config-source"))
}
@Test("status argv supports LingTai TUI provider")
func statusSubcommandSupportsLingTaiTUI() {
let args = DataClient.statusSubcommand(
period: .month,
provider: .lingtaiTui,
includeOptimize: false,
scope: .local
)
#expect(value(after: "--provider", in: args) == "lingtai-tui")
#expect(value(after: "--period", in: args) == "month")
}
/// Concurrency + timeout smoke test: launch more hung subprocesses than
/// there are cooperative threads, all at once, with a short timeout, and
/// assert every call returns once the timeout kills its sleep.
///
/// NOTE: this does NOT reproduce the production permanent deadlock (16/16
/// cooperative threads parked in waitUntilExit). In a short-lived unit-test
/// process libdispatch spins up replacement threads for blocked workers, so
/// even the old blocking-on-the-pool code completes here. The real deadlock
/// built up over ~2 days under the @MainActor refresh loop and is confirmed
/// by the live `sample`, not by this test. Kept as a guard that the
/// off-pool wait + timeout path stays correct under concurrency.
@Test("concurrent timed-out processes all complete")
func concurrentTimedOutProcessesAllComplete() {
let count = ProcessInfo.processInfo.activeProcessorCount * 2 + 4
let done = DispatchSemaphore(value: 0)
Task {
await withTaskGroup(of: Void.self) { group in
for _ in 0..<count {
group.addTask {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sleep")
process.arguments = ["30"]
_ = try? await DataClient.runProcess(process, timeoutSeconds: 1, label: "sleep 30")
}
}
}
done.signal()
}
// Wait on the test thread (a real thread, not the cooperative pool) so
// the deadlock is detectable even when the pool is fully starved.
let outcome = done.wait(timeout: .now() + 15)
#expect(outcome == .success, "runProcess deadlocked: \(count) concurrent CLIs starved the cooperative pool")
}
/// A decode failure surfaces the CLI's actual stdout/stderr so a stray banner
/// on stdout (see #515) is self-diagnosing instead of an opaque "not valid JSON".
@Test("decode failure surfaces output")
func decodeFailureSurfacesOutput() {
struct Boom: Error {}
let failure = CLIDecodeFailure(
underlying: Boom(),
stdoutByteCount: 13,
stdoutSnippet: "(node) banner",
stderr: "warn: x"
)
let text = String(describing: failure)
#expect(text.contains("(node) banner"), "should include the stdout snippet")
#expect(text.contains("13 bytes"), "should include the stdout byte count")
#expect(text.contains("warn: x"), "should include stderr")
}
/// Empty stdout is reported distinctly (the JSONDecoder-on-empty-Data case).
@Test("decode failure with empty stdout")
func decodeFailureWithEmptyStdout() {
struct Boom: Error {}
let failure = CLIDecodeFailure(underlying: Boom(), stdoutByteCount: 0, stdoutSnippet: "", stderr: "")
let text = String(describing: failure)
#expect(text.contains("0 bytes"))
#expect(text.contains("<empty>"))
}
/// A normally-exiting process returns its real output and exit code through
/// the off-pool wait path.
@Test("process returns output and exit code")
func processReturnsOutputAndExitCode() async throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/echo")
process.arguments = ["hello"]
let result = try await DataClient.runProcess(process, timeoutSeconds: 5, label: "echo hello")
#expect(result.exitCode == 0)
#expect(String(data: result.stdout, encoding: .utf8) == "hello\n")
}
/// Many NORMALLY-exiting processes, all at once, must every one complete
/// through the terminationHandler wait path. Guards against the wait path
/// leaking or wedging under concurrency (the production bug was the wait and
/// its timeout sharing one queue that saturated under sustained load).
@Test("many normal processes all complete")
func manyNormalProcessesAllComplete() async {
let count = 50
let codes = await withTaskGroup(of: Int32?.self) { group -> [Int32?] in
for _ in 0..<count {
group.addTask {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/echo")
process.arguments = ["ok"]
return try? await DataClient.runProcess(process, timeoutSeconds: 5, label: "echo ok").exitCode
}
}
var out: [Int32?] = []
for await code in group { out.append(code) }
return out
}
#expect(codes.count == count)
#expect(codes.allSatisfy { $0 == 0 },
"every concurrent process should exit 0 via the terminationHandler wait path")
}
/// The async semaphore never lets more than its count run concurrently.
@Test("async semaphore caps concurrency")
func asyncSemaphoreCapsConcurrency() async {
let sem = AsyncSemaphore(2)
let peak = PeakCounter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<12 {
group.addTask {
await sem.acquire()
await peak.enter()
try? await Task.sleep(nanoseconds: 8_000_000)
await peak.leave()
await sem.release()
}
}
}
let observed = await peak.peak
#expect(observed <= 2, "semaphore should cap concurrency at 2, saw \(observed)")
#expect(observed > 0)
}
}
private func value(after flag: String, in args: [String]) -> String? {
guard let index = args.firstIndex(of: flag), args.indices.contains(index + 1) else {
return nil
}
return args[index + 1]
}
private actor PeakCounter {
private var current = 0
private(set) var peak = 0
func enter() { current += 1; peak = max(peak, current) }
func leave() { current -= 1 }
}
@@ -0,0 +1,130 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("MenubarPayload combined")
struct MenubarPayloadCombinedTests {
@Test("decodes combined block")
func decodesCombinedBlock() throws {
let json = combinedPayloadJSON()
let payload = try JSONDecoder().decode(MenubarPayload.self, from: json)
#expect(payload.combined?.perDevice.count == 2)
#expect(payload.combined?.perDevice.first?.id == "local-device")
#expect(payload.combined?.perDevice.first?.local == true)
#expect(payload.combined?.perDevice.last?.error == "offline")
#expect(payload.combined?.combined.cost == 4.5)
#expect(payload.combined?.combined.calls == 7)
#expect(payload.combined?.combined.deviceCount == 2)
#expect(payload.combined?.combined.reachableCount == 1)
}
@Test("combined hero token total excludes cache tokens")
func combinedHeroTokenTotalExcludesCacheTokens() throws {
let payload = try JSONDecoder().decode(MenubarPayload.self, from: combinedPayloadJSON())
let totals = HeroTotals(payload: payload, activeScope: .combined)
#expect(payload.combined?.combined.totalTokens == 2000)
#expect(totals.inputTokens == 1000)
#expect(totals.outputTokens == 500)
#expect(totals.totalTokens == 1500)
}
@Test("combined block is nil when absent")
func combinedBlockIsNilWhenAbsent() throws {
let json = Data("""
{
"generated": "2026-06-24T00:00:00Z",
"current": {
"label": "Today",
"cost": 1.25,
"calls": 2,
"sessions": 1,
"inputTokens": 100,
"outputTokens": 50
},
"optimize": {
"findingCount": 0,
"savingsUSD": 0,
"topFindings": []
},
"history": {
"daily": []
}
}
""".utf8)
let payload = try JSONDecoder().decode(MenubarPayload.self, from: json)
#expect(payload.combined == nil)
}
}
private func combinedPayloadJSON() -> Data {
Data("""
{
"generated": "2026-06-24T00:00:00Z",
"current": {
"label": "Today",
"cost": 1.25,
"calls": 2,
"sessions": 1,
"inputTokens": 100,
"outputTokens": 50
},
"optimize": {
"findingCount": 0,
"savingsUSD": 0,
"topFindings": []
},
"history": {
"daily": []
},
"combined": {
"perDevice": [
{
"id": "local-device",
"name": "MacBook Pro",
"local": true,
"error": null,
"cost": 4.5,
"calls": 7,
"sessions": 3,
"inputTokens": 1000,
"outputTokens": 500,
"cacheCreateTokens": 200,
"cacheReadTokens": 300,
"totalTokens": 2000
},
{
"id": "remote-device",
"name": "Studio",
"local": false,
"error": "offline",
"cost": 0,
"calls": 0,
"sessions": 0,
"inputTokens": 0,
"outputTokens": 0,
"cacheCreateTokens": 0,
"cacheReadTokens": 0,
"totalTokens": 0
}
],
"combined": {
"cost": 4.5,
"calls": 7,
"sessions": 3,
"inputTokens": 1000,
"outputTokens": 500,
"cacheCreateTokens": 200,
"cacheReadTokens": 300,
"totalTokens": 2000,
"deviceCount": 2,
"reachableCount": 1
}
}
}
""".utf8)
}
@@ -0,0 +1,71 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("Menubar period settings")
struct MenubarPeriodSettingsTests {
@Test("settings picker exposes requested periods")
func settingsPickerExposesRequestedPeriods() {
#expect(Period.menubarMetricCases == [.today, .sevenDays, .month, .all])
}
@Test("defaults values map to periods")
func defaultsValuesMapToPeriods() {
#expect(Period(menubarDefaultsValue: "today") == .today)
#expect(Period(menubarDefaultsValue: "week") == .sevenDays)
#expect(Period(menubarDefaultsValue: "month") == .month)
#expect(Period(menubarDefaultsValue: "sixMonths") == .all)
#expect(Period(menubarDefaultsValue: "all") == .all)
#expect(Period(menubarDefaultsValue: "30days") == .today)
#expect(Period(menubarDefaultsValue: "bogus") == .today)
#expect(Period(menubarDefaultsValue: nil) == .today)
}
@Test("periods persist canonical defaults values")
func periodsPersistCanonicalDefaultsValues() {
let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
Period.sevenDays.persistAsMenubarDefault(defaults: defaults)
#expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "week")
#expect(Period.savedMenubarPeriod(defaults: defaults) == .sevenDays)
Period.all.persistAsMenubarDefault(defaults: defaults)
#expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "sixMonths")
#expect(Period.savedMenubarPeriod(defaults: defaults) == .all)
Period.thirtyDays.persistAsMenubarDefault(defaults: defaults)
#expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "today")
#expect(Period.savedMenubarPeriod(defaults: defaults) == .today)
}
@Test("menubar scope persistence defaults to local and round-trips")
func menubarScopePersistenceDefaultsToLocalAndRoundTrips() {
let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
#expect(MenubarScope.savedMenubarScope(defaults: defaults) == .local)
MenubarScope.combined.persistAsMenubarDefault(defaults: defaults)
#expect(defaults.string(forKey: "CodeBurnMenubarScope") == "combined")
#expect(MenubarScope.savedMenubarScope(defaults: defaults) == .combined)
MenubarScope.local.persistAsMenubarDefault(defaults: defaults)
#expect(defaults.string(forKey: "CodeBurnMenubarScope") == "local")
#expect(MenubarScope.savedMenubarScope(defaults: defaults) == .local)
#expect(MenubarScope(menubarDefaultsValue: "bogus") == .local)
}
@Test("non-today periods render compact and regular suffixes")
func nonTodayPeriodsRenderCompactAndRegularSuffixes() {
#expect(Period.today.menubarSuffix(compact: false) == "")
#expect(Period.sevenDays.menubarSuffix(compact: false) == " / wk")
#expect(Period.month.menubarSuffix(compact: false) == " / mo")
#expect(Period.all.menubarSuffix(compact: false) == " / 6mo")
#expect(Period.sevenDays.menubarSuffix(compact: true) == "/wk")
#expect(Period.month.menubarSuffix(compact: true) == "/mo")
#expect(Period.all.menubarSuffix(compact: true) == "/6mo")
}
}
@@ -0,0 +1,87 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("MenubarStatusCache")
struct MenubarStatusCacheTests {
private func tempDir() -> String {
let dir = NSTemporaryDirectory() + "menubar-status-test-" + UUID().uuidString
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
return dir
}
private func validPayloadJSON(cost: Double) -> Data {
let p = MenubarPayload(
generated: "2026-05-29T00:00:00Z",
current: CurrentBlock(
label: "Today", cost: cost, calls: 1, sessions: 1, oneShotRate: nil,
inputTokens: 1, outputTokens: 1, cacheHitPercent: 0,
codexCredits: nil,
topActivities: [], topModels: [], localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []), providers: ["claude": cost],
topProjects: [], modelEfficiency: [], topSessions: [],
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []),
tools: [], skills: [], subagents: [], mcpServers: []
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: []),
combined: nil
)
return try! JSONEncoder().encode(p)
}
@Test("reads a fresh, valid status file")
func readsValidFile() throws {
let dir = tempDir()
let path = dir + "/menubar-status.json"
try validPayloadJSON(cost: 42.5).write(to: URL(fileURLWithPath: path))
let cache = MenubarStatusCache(statusPath: path)
let result = cache.readBadgePayload(maxAgeSeconds: 3600)
#expect(result?.payload.current.cost == 42.5)
#expect((result?.ageSeconds ?? .infinity) < 60)
}
@Test("returns nil for a missing file")
func missingFileReturnsNil() {
let dir = tempDir()
let cache = MenubarStatusCache(statusPath: dir + "/nope.json")
#expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil)
}
@Test("returns nil for a corrupt file")
func corruptFileReturnsNil() throws {
let dir = tempDir()
let path = dir + "/menubar-status.json"
try Data("{ not json".utf8).write(to: URL(fileURLWithPath: path))
let cache = MenubarStatusCache(statusPath: path)
#expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil)
}
@Test("returns nil for an over-age file")
func overAgeFileReturnsNil() throws {
let dir = tempDir()
let path = dir + "/menubar-status.json"
let url = URL(fileURLWithPath: path)
try validPayloadJSON(cost: 7).write(to: url)
try FileManager.default.setAttributes(
[.modificationDate: Date().addingTimeInterval(-7200)], ofItemAtPath: path
)
let cache = MenubarStatusCache(statusPath: path)
#expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil)
}
@Test("writeStatus round-trips through readBadgePayload")
func writeStatusRoundTrips() throws {
let dir = tempDir()
let path = dir + "/menubar-status.json"
let cache = MenubarStatusCache(statusPath: path)
let payload = try JSONDecoder().decode(MenubarPayload.self, from: validPayloadJSON(cost: 13.5))
try cache.writeStatus(payload)
let result = cache.readBadgePayload(maxAgeSeconds: 3600)
#expect(result?.payload.current.cost == 13.5)
}
}
@@ -0,0 +1,80 @@
import XCTest
@testable import CodeBurnMenubar
final class RefreshCadenceTests: XCTestCase {
func testAutoPopoverOpenAlwaysUsesActiveCadence() {
XCTAssertEqual(
RefreshCadence.interval(mode: .auto, popoverOpen: true, onBattery: true, lowPowerMode: true),
RefreshCadence.activeSeconds
)
}
func testAutoIdleOnACStaysActive() {
XCTAssertEqual(
RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: false, lowPowerMode: false),
RefreshCadence.activeSeconds
)
}
func testAutoIdleOnBatteryBacksOff() {
XCTAssertEqual(
RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: true, lowPowerMode: false),
RefreshCadence.batteryIdleSeconds
)
}
func testAutoLowPowerModeBacksOffFurthest() {
XCTAssertEqual(
RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: true, lowPowerMode: true),
RefreshCadence.lowPowerIdleSeconds
)
XCTAssertEqual(
RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: false, lowPowerMode: true),
RefreshCadence.lowPowerIdleSeconds
)
}
func testManualNeverAutoSpawns() {
XCTAssertNil(RefreshCadence.interval(mode: .manual, popoverOpen: false, onBattery: false, lowPowerMode: false))
XCTAssertNil(RefreshCadence.interval(mode: .manual, popoverOpen: true, onBattery: false, lowPowerMode: false))
}
func testFixedCadenceIgnoresPowerState() {
XCTAssertEqual(
RefreshCadence.interval(mode: .fiveMinutes, popoverOpen: false, onBattery: true, lowPowerMode: true),
300
)
XCTAssertEqual(
RefreshCadence.interval(mode: .fifteenMinutes, popoverOpen: false, onBattery: false, lowPowerMode: false),
900
)
}
func testFixedCadenceGoesActiveWhilePopoverOpen() {
XCTAssertEqual(
RefreshCadence.interval(mode: .fiveMinutes, popoverOpen: true, onBattery: true, lowPowerMode: false),
RefreshCadence.activeSeconds
)
}
func testBackoffOrdering() {
XCTAssertLessThan(RefreshCadence.activeSeconds, RefreshCadence.batteryIdleSeconds)
XCTAssertLessThan(RefreshCadence.batteryIdleSeconds, RefreshCadence.lowPowerIdleSeconds)
}
func testCadenceDefaultsToAutoWhenUnset() {
let key = UsageRefreshCadence.defaultsKey
let saved = UserDefaults.standard.object(forKey: key)
defer {
if let saved { UserDefaults.standard.set(saved, forKey: key) }
else { UserDefaults.standard.removeObject(forKey: key) }
}
UserDefaults.standard.removeObject(forKey: key)
XCTAssertEqual(UsageRefreshCadence.current, .auto)
UsageRefreshCadence.current = .manual
XCTAssertEqual(UsageRefreshCadence.current, .manual)
UsageRefreshCadence.current = .fiveMinutes
XCTAssertEqual(UsageRefreshCadence.current, .fiveMinutes)
}
}
@@ -0,0 +1,60 @@
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)
}
@Test("flags CLI older than the menubar-update fix as too old")
func flagsCliBelowMinimumAsTooOld() {
#expect(UpdateChecker.isCliTooOld(installed: "0.9.8"))
#expect(UpdateChecker.isCliTooOld(installed: "v0.9.8"))
#expect(UpdateChecker.isCliTooOld(installed: "0.8.12"))
}
@Test("accepts CLI at or above the menubar-update fix version")
func acceptsCliAtOrAboveMinimum() {
#expect(!UpdateChecker.isCliTooOld(installed: "0.9.9"))
#expect(!UpdateChecker.isCliTooOld(installed: "0.9.10"))
#expect(!UpdateChecker.isCliTooOld(installed: "0.9.14"))
#expect(!UpdateChecker.isCliTooOld(installed: "1.0.0"))
}
@Test("does not flag when the CLI version is unknown")
func ignoresUnknownCliVersion() {
#expect(!UpdateChecker.isCliTooOld(installed: nil))
#expect(!UpdateChecker.isCliTooOld(installed: ""))
}
}