feat(menubar): add MenubarStatusCache read side for badge backstop

This commit is contained in:
iamtoruk 2026-05-30 00:01:07 -07:00
parent 75edff64ec
commit bdbd7b19f6
2 changed files with 115 additions and 0 deletions

View file

@ -0,0 +1,43 @@
import Foundation
/// On-disk badge backstop. A static LaunchAgent runs `menubar-refresh.sh` every
/// 30s and atomically writes `menubar-status.json`; the app reads it as a badge
/// fallback when the in-app refresh loop is behind or dead. Shares the
/// `MenubarPayload` decoder with the live path no separate data model.
struct MenubarStatusCache {
let statusPath: String
let scriptPath: String
/// Default locations under `~/.cache/codeburn/`.
static func standard() -> MenubarStatusCache {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let dir = "\(home)/.cache/codeburn"
return MenubarStatusCache(
statusPath: "\(dir)/menubar-status.json",
scriptPath: "\(dir)/menubar-refresh.sh"
)
}
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)
}
}

View file

@ -0,0 +1,72 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("MenubarStatusCache")
struct MenubarStatusCacheTests {
private func tempDir() -> String {
let dir = NSTemporaryDirectory() + "menubar-status-test-" + UUID().uuidString
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
return dir
}
private func validPayloadJSON(cost: Double) -> Data {
let p = MenubarPayload(
generated: "2026-05-29T00:00:00Z",
current: CurrentBlock(
label: "Today", cost: cost, calls: 1, sessions: 1, oneShotRate: nil,
inputTokens: 1, outputTokens: 1, cacheHitPercent: 0,
topActivities: [], topModels: [], providers: ["claude": cost],
topProjects: [], modelEfficiency: [], topSessions: [],
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []),
tools: [], skills: [], subagents: [], mcpServers: []
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: [])
)
return try! JSONEncoder().encode(p)
}
@Test("reads a fresh, valid status file")
func readsValidFile() throws {
let dir = tempDir()
let path = dir + "/menubar-status.json"
try validPayloadJSON(cost: 42.5).write(to: URL(fileURLWithPath: path))
let cache = MenubarStatusCache(statusPath: path, scriptPath: dir + "/menubar-refresh.sh")
let result = cache.readBadgePayload(maxAgeSeconds: 3600)
#expect(result?.payload.current.cost == 42.5)
#expect((result?.ageSeconds ?? .infinity) < 60)
}
@Test("returns nil for a missing file")
func missingFileReturnsNil() {
let dir = tempDir()
let cache = MenubarStatusCache(statusPath: dir + "/nope.json", scriptPath: dir + "/s.sh")
#expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil)
}
@Test("returns nil for a corrupt file")
func corruptFileReturnsNil() throws {
let dir = tempDir()
let path = dir + "/menubar-status.json"
try Data("{ not json".utf8).write(to: URL(fileURLWithPath: path))
let cache = MenubarStatusCache(statusPath: path, scriptPath: dir + "/s.sh")
#expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil)
}
@Test("returns nil for an over-age file")
func overAgeFileReturnsNil() throws {
let dir = tempDir()
let path = dir + "/menubar-status.json"
let url = URL(fileURLWithPath: path)
try validPayloadJSON(cost: 7).write(to: url)
try FileManager.default.setAttributes(
[.modificationDate: Date().addingTimeInterval(-7200)], ofItemAtPath: path
)
let cache = MenubarStatusCache(statusPath: path, scriptPath: dir + "/s.sh")
#expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil)
}
}