diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift new file mode 100644 index 0000000..75f8621 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift @@ -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) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift new file mode 100644 index 0000000..251766b --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift @@ -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) + } +}