chore: import upstream snapshot with attribution
This commit is contained in:
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 USD→X 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
|
||||
/// yellow→orange→red, 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user