From 38252d9c120e8af563ffd9d77efbfd7268d334b4 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:30:47 +0530 Subject: [PATCH] menubar: skip unchanged background refreshes, 120s AC idle cadence, utility QoS for tick spawns (#703) --- mac/Sources/CodeBurnMenubar/AppStore.swift | 142 +++++++++++---- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 144 ++++++++++++--- .../CodeBurnMenubar/Data/DataClient.swift | 12 +- .../Data/UsageDataChangeGuard.swift | 172 ++++++++++++++++++ .../Data/UsageRefreshCadence.swift | 7 +- .../CodeBurnMenubar/RefreshCadence.swift | 7 +- .../Security/CodeburnCLI.swift | 7 +- .../RefreshCadenceTests.swift | 4 +- .../UsageDataChangeGuardTests.swift | 79 ++++++++ 9 files changed, 495 insertions(+), 79 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/UsageDataChangeGuardTests.swift diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index f678d2d6..7476c92c 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -523,19 +523,19 @@ final class AppStore { lastErrorByKey[key] = nil switchTask = Task { if scope == .combined { - async let local: Void = refresh(key: localKey, includeOptimize: false, force: false, showLoading: false) - async let combined: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true) + async let local = refresh(key: localKey, includeOptimize: false, force: false, showLoading: false) + async let combined = refresh(key: key, includeOptimize: false, force: true, showLoading: true) if provider == .all { _ = await (local, combined) } else { - async let all: Void = refreshQuietly(key: allKey, includeOptimize: false, force: false) + async let all = refreshQuietly(key: allKey, includeOptimize: false, force: false) _ = await (local, combined, all) } } else if provider == .all { await refresh(key: key, includeOptimize: false, force: true, showLoading: true) } else { - async let main: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true) - async let all: Void = refreshQuietly(key: allKey, includeOptimize: false, force: false) + async let main = refresh(key: key, includeOptimize: false, force: true, showLoading: true) + async let all = refreshQuietly(key: allKey, includeOptimize: false, force: false) _ = await (main, all) } } @@ -645,9 +645,10 @@ final class AppStore { } } - func recoverFromStuckLoading() async { - guard prepareStuckLoadingRecovery() else { return } - await refresh(includeOptimize: false, force: true, showLoading: true) + @discardableResult + func recoverFromStuckLoading() async -> Bool { + guard prepareStuckLoadingRecovery() else { return false } + return await refresh(includeOptimize: false, force: true, showLoading: true) } /// Decides whether stuck-loading recovery should kick off a fresh fetch for @@ -672,13 +673,37 @@ final class AppStore { lastErrorByKey[currentKey] = "Could not load \(label). Check that the codeburn CLI is installed and working." } - func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async { + @discardableResult + func refresh( + includeOptimize: Bool, + force: Bool = false, + showLoading: Bool = false, + qualityOfService: QualityOfService = .userInitiated + ) async -> Bool { if effectiveSelectedScope == .combined { - async let local: Void = refreshQuietly(key: localCurrentKey, includeOptimize: includeOptimize, force: force) - async let combined: Void = refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading) - _ = await (local, combined) + async let local = refreshQuietly( + key: localCurrentKey, + includeOptimize: includeOptimize, + force: force, + qualityOfService: qualityOfService + ) + async let combined = refresh( + key: currentKey, + includeOptimize: includeOptimize, + force: force, + showLoading: showLoading, + qualityOfService: qualityOfService + ) + let (localSucceeded, combinedSucceeded) = await (local, combined) + return localSucceeded && combinedSucceeded } else { - await refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading) + return await refresh( + key: currentKey, + includeOptimize: includeOptimize, + force: force, + showLoading: showLoading, + qualityOfService: qualityOfService + ) } } @@ -692,21 +717,28 @@ final class AppStore { claudeConfigSourceId: selectedClaudeConfigSourceId ) if scope == .combined { - async let local: Void = refreshQuietly(key: localCurrentKey, includeOptimize: false, force: false) - async let combined: Void = refreshQuietly(key: scopedKey, includeOptimize: false, force: force) + async let local = refreshQuietly(key: localCurrentKey, includeOptimize: false, force: false) + async let combined = refreshQuietly(key: scopedKey, includeOptimize: false, force: force) _ = await (local, combined) } else { await refreshQuietly(key: scopedKey, includeOptimize: false, force: force) } } - private func refresh(key: PayloadCacheKey, includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async { + @discardableResult + private func refresh( + key: PayloadCacheKey, + includeOptimize: Bool, + force: Bool = false, + showLoading: Bool = false, + qualityOfService: QualityOfService = .userInitiated + ) async -> Bool { invalidateStaleDayCache() let cacheDateAtStart = cacheDate let generationAtStart = payloadRefreshGeneration - if Task.isCancelled { return } - if !force, cache[key]?.isFresh == true { return } - if inFlightKeys[key] != nil { return } + if Task.isCancelled { return false } + if !force, cache[key]?.isFresh == true { return true } + if inFlightKeys[key] != nil { return false } inFlightKeys[key] = Date() attemptedKeys.insert(key) lastErrorByKey[key] = nil @@ -735,6 +767,7 @@ final class AppStore { attemptedKeys.remove(key) } } + var succeeded = false do { let fresh = try await DataClient.fetch( period: key.period, @@ -743,11 +776,12 @@ final class AppStore { provider: key.provider, includeOptimize: includeOptimize, scope: key.scope, - claudeConfigSourceId: key.claudeConfigSourceId + claudeConfigSourceId: key.claudeConfigSourceId, + qualityOfService: qualityOfService ) if generationAtStart != payloadRefreshGeneration { NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch") - return + return false } if Task.isCancelled { // Distinguish cancellation (user switched tabs mid-fetch) from @@ -755,7 +789,7 @@ final class AppStore { // fetch leaves cache empty + lastError nil and the user sees // perpetual loading with nothing in the diagnostics. NSLog("CodeBurn: fetch for \(key.label)/\(key.provider.rawValue) cancelled before result was applied") - return + return false } // Day-rollover race guard: if the calendar date changed during the // fetch, this payload was computed against yesterday's date and @@ -764,14 +798,15 @@ final class AppStore { if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { invalidateStaleDayCache() NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — calendar rolled mid-fetch") - return + return false } cache[key] = CachedPayload(payload: fresh, fetchedAt: Date()) reconcileClaudeConfigSelection(from: fresh, for: key) lastSuccessByKey[key] = Date() lastErrorByKey[key] = nil + succeeded = true } catch { - if Task.isCancelled { return } + if Task.isCancelled { return false } NSLog("CodeBurn: fetch failed for \(key.label)/\(key.provider.rawValue): \(error)") if includeOptimize, cache[key] == nil { do { @@ -782,27 +817,30 @@ final class AppStore { provider: key.provider, includeOptimize: false, scope: key.scope, - claudeConfigSourceId: key.claudeConfigSourceId + claudeConfigSourceId: key.claudeConfigSourceId, + qualityOfService: qualityOfService ) - guard !Task.isCancelled else { return } - if generationAtStart != payloadRefreshGeneration { return } + guard !Task.isCancelled else { return false } + if generationAtStart != payloadRefreshGeneration { return false } if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { invalidateStaleDayCache() - return + return false } cache[key] = CachedPayload(payload: fallback, fetchedAt: Date()) reconcileClaudeConfigSelection(from: fallback, for: key) lastSuccessByKey[key] = Date() lastErrorByKey[key] = nil - return + return true } catch { - if Task.isCancelled { return } + if Task.isCancelled { return false } NSLog("CodeBurn: fallback fetch also failed: \(error)") } } lastErrorByKey[key] = String(describing: error) } + guard succeeded else { return false } + let allKey = PayloadCacheKey( scope: .local, period: key.period, @@ -812,23 +850,46 @@ final class AppStore { claudeConfigSourceId: key.claudeConfigSourceId ) if key != allKey, cache[allKey]?.isFresh != true { - await refreshQuietly(key: allKey, includeOptimize: false, force: false) + await refreshQuietly( + key: allKey, + includeOptimize: false, + force: false, + qualityOfService: qualityOfService + ) } + return true } /// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge). /// Does not toggle isLoading, so the popover's loading overlay is unaffected. /// Always uses the .all provider since the menubar badge shows total spend. - func refreshQuietly(period: Period, day: String? = nil, force: Bool = false) async { + @discardableResult + func refreshQuietly( + period: Period, + day: String? = nil, + force: Bool = false, + qualityOfService: QualityOfService = .userInitiated + ) async -> Bool { // Scope the status-payload fetch to the selected config so the menu-bar // figure matches the popover (see menubarStatusKey). - await refreshQuietly(key: PayloadCacheKey(scope: .local, period: period, provider: .all, day: day, claudeConfigSourceId: selectedClaudeConfigSourceId), includeOptimize: false, force: force) + return await refreshQuietly( + key: PayloadCacheKey(scope: .local, period: period, provider: .all, day: day, claudeConfigSourceId: selectedClaudeConfigSourceId), + includeOptimize: false, + force: force, + qualityOfService: qualityOfService + ) } - private func refreshQuietly(key: PayloadCacheKey, includeOptimize: Bool, force: Bool = false) async { + @discardableResult + private func refreshQuietly( + key: PayloadCacheKey, + includeOptimize: Bool, + force: Bool = false, + qualityOfService: QualityOfService = .userInitiated + ) async -> Bool { invalidateStaleDayCache() - if !force, cache[key]?.isFresh == true { return } - if inFlightKeys[key] != nil { return } + if !force, cache[key]?.isFresh == true { return true } + if inFlightKeys[key] != nil { return false } inFlightKeys[key] = Date() attemptedKeys.insert(key) let cacheDateAtStart = cacheDate @@ -847,17 +908,18 @@ final class AppStore { provider: key.provider, includeOptimize: includeOptimize, scope: key.scope, - claudeConfigSourceId: key.claudeConfigSourceId + claudeConfigSourceId: key.claudeConfigSourceId, + qualityOfService: qualityOfService ) if generationAtStart != payloadRefreshGeneration { NSLog("CodeBurn: dropping quiet fetch result for \(key.label) — refresh pipeline reset mid-fetch") - return + return false } // Same day-rollover guard as refresh(): drop yesterday's payload if // the calendar rolled over during the fetch. if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { invalidateStaleDayCache() - return + return false } cache[key] = CachedPayload(payload: fresh, fetchedAt: Date()) reconcileClaudeConfigSelection(from: fresh, for: key) @@ -868,7 +930,9 @@ final class AppStore { if key.scope == .combined { lastErrorByKey[key] = String(describing: error) } + return false } + return true } /// User-initiated. Reads Claude's source (this is what triggers the macOS keychain diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 34b68d63..fe591f80 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -270,6 +270,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } private var lastRefreshTime: Date = .distantPast + /// Anchors the shallow provider-root snapshot only after a complete usage + /// refresh succeeds. It sits beside the cadence anchor so a failed fetch + /// never makes a later unchanged tick look successful. + private var lastSuccessfulUsageDataSnapshot: UsageDataSnapshot? + private var lastSuccessfulUsageDataSnapshotAt: Date? + + private func recordSuccessfulUsageDataSnapshot() { + lastSuccessfulUsageDataSnapshot = UsageDataChangeGuard.snapshot() + lastSuccessfulUsageDataSnapshotAt = Date() + } + + private func shouldSkipBackgroundUsageRefresh() -> Bool { + let current = UsageDataChangeGuard.snapshot() + let shouldSkip = UsageDataChangeGuard.shouldSkip( + current: current, + lastSuccessful: lastSuccessfulUsageDataSnapshot, + lastSuccessAt: lastSuccessfulUsageDataSnapshotAt, + force: false + ) + if shouldSkip { + NSLog("CodeBurn: skipping unchanged background usage refresh") + } + return shouldSkip + } @discardableResult private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool { @@ -317,7 +341,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { return false } - private func refreshStatusPayloadIfNeeded(reason: String, force: Bool = false, minAgeSeconds: TimeInterval = 0) { + private func refreshStatusPayloadIfNeeded( + reason: String, + force: Bool = false, + minAgeSeconds: TimeInterval = 0, + qualityOfService: QualityOfService = .userInitiated + ) { let now = Date() _ = clearStaleStatusPayloadRefreshIfNeeded(now: now) guard statusPayloadRefreshTask == nil else { return } @@ -341,7 +370,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let activity = ProcessInfo.processInfo.beginActivity( options: .background, reason: "CodeBurn status refresh") defer { ProcessInfo.processInfo.endActivity(activity) } - await self.store.refreshQuietly(period: menubarPeriod, force: true) + let succeeded = await self.store.refreshQuietly( + period: menubarPeriod, + force: true, + qualityOfService: qualityOfService + ) + if succeeded { + self.recordSuccessfulUsageDataSnapshot() + } self.refreshStatusButton() guard self.statusPayloadRefreshGeneration == generation, !Task.isCancelled else { return } self.statusPayloadRefreshTask = nil @@ -349,11 +385,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } - private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) { + private func forceRefresh( + bypassRateLimit: Bool = false, + forceQuota: Bool = false, + qualityOfService: QualityOfService = .userInitiated + ) { let now = Date() _ = clearStaleForceRefreshIfNeeded(now: now) if forceRefreshTask != nil { - refreshStatusPayloadIfNeeded(reason: "blocked force refresh") + if qualityOfService != .utility || !shouldSkipBackgroundUsageRefresh() { + refreshStatusPayloadIfNeeded(reason: "blocked force refresh", qualityOfService: qualityOfService) + } } guard forceRefreshTask == nil else { return } if !bypassRateLimit { @@ -368,9 +410,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let activity = ProcessInfo.processInfo.beginActivity( options: .background, reason: "CodeBurn refresh") defer { ProcessInfo.processInfo.endActivity(activity) } - async let main: Void = refreshUsagePayloads(force: true, showLoading: true) + async let main = refreshUsagePayloads( + force: true, + showLoading: true, + qualityOfService: qualityOfService + ) async let quotas: Bool = refreshLiveQuotaProgressIfDue(force: forceQuota) - _ = await main + let mainSucceeded = await main + if mainSucceeded { + recordSuccessfulUsageDataSnapshot() + } refreshStatusButton() _ = await quotas await MainActor.run { [weak self] in @@ -382,7 +431,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } - private func refreshUsagePayloads(force: Bool, showLoading: Bool = false) async { + private func refreshUsagePayloads( + force: Bool, + showLoading: Bool = false, + qualityOfService: QualityOfService = .userInitiated + ) async -> Bool { let menubarPeriod = store.menubarPeriod // With the popover closed, only the payloads the status item actually @@ -391,25 +444,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // is refreshed by refreshPayloadForPopoverOpen the moment it opens, // so a closed-popover tick never pays for it (#647). if !(popover?.isShown ?? false) { - async let menubar: Void = store.refreshQuietly(period: menubarPeriod, force: force) - async let today: Void = menubarPeriod != .today - ? store.refreshQuietly(period: .today, force: force) - : () - _ = await (menubar, today) - return + async let menubar = store.refreshQuietly( + period: menubarPeriod, + force: force, + qualityOfService: qualityOfService + ) + async let today = menubarPeriod != .today + ? store.refreshQuietly(period: .today, force: force, qualityOfService: qualityOfService) + : true + let (menubarSucceeded, todaySucceeded) = await (menubar, today) + return menubarSucceeded && todaySucceeded } let needsMenubarPayload = store.selectedPeriod != menubarPeriod || store.selectedProvider != .all let needsTodayPayload = (store.selectedPeriod != .today || store.selectedProvider != .all) && menubarPeriod != .today - async let visible: Void = store.refresh(includeOptimize: false, force: force, showLoading: showLoading) - async let menubar: Void = needsMenubarPayload - ? store.refreshQuietly(period: menubarPeriod, force: force) - : () - async let today: Void = needsTodayPayload - ? store.refreshQuietly(period: .today, force: force) - : () - _ = await (visible, menubar, today) + async let visible = store.refresh( + includeOptimize: false, + force: force, + showLoading: showLoading, + qualityOfService: qualityOfService + ) + async let menubar = needsMenubarPayload + ? store.refreshQuietly(period: menubarPeriod, force: force, qualityOfService: qualityOfService) + : true + async let today = needsTodayPayload + ? store.refreshQuietly(period: .today, force: force, qualityOfService: qualityOfService) + : true + let (visibleSucceeded, menubarSucceeded, todaySucceeded) = await (visible, menubar, today) + return visibleSucceeded && menubarSucceeded && todaySucceeded } /// Loads the currency code persisted by `codeburn currency` so a relaunch picks up where @@ -532,7 +595,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } Task { [weak self] in guard let self else { return } - await self.store.recoverFromStuckLoading() + let succeeded = await self.store.recoverFromStuckLoading() + if succeeded { + self.recordSuccessfulUsageDataSnapshot() + } self.refreshStatusButton() } } @@ -580,15 +646,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let shouldForceRefresh = forcePayload || ((clearedStaleForceRefresh || clearedStaleLoading) && recoveryRespawnAllowed) || intervalElapsed + let popoverOpen = popover?.isShown ?? false + let interactiveUsageRefresh = forcePayload || popoverOpen + let qualityOfService: QualityOfService = interactiveUsageRefresh ? .userInitiated : .utility + var skippedUnchangedUsageRefresh = false if shouldForceRefresh { - forceRefresh(bypassRateLimit: true, forceQuota: forceQuota) + skippedUnchangedUsageRefresh = !interactiveUsageRefresh && shouldSkipBackgroundUsageRefresh() + if !skippedUnchangedUsageRefresh { + forceRefresh( + bypassRateLimit: true, + forceQuota: forceQuota, + qualityOfService: qualityOfService + ) + } } let forceRefreshWasBlocked = hadForceRefreshInFlight && forceRefreshTask != nil if statusPayloadStale && (!shouldForceRefresh || forceRefreshWasBlocked || clearedStaleStatusRefresh) { guard forcePayload || interval != nil else { return } - refreshStatusPayloadIfNeeded(reason: reason, force: forcePayload, minAgeSeconds: interval ?? 0) + if !interactiveUsageRefresh && (skippedUnchangedUsageRefresh || shouldSkipBackgroundUsageRefresh()) { + return + } + refreshStatusPayloadIfNeeded( + reason: reason, + force: forcePayload, + minAgeSeconds: interval ?? 0, + qualityOfService: qualityOfService + ) } } @@ -649,9 +734,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // "Refresh Now" should refresh the menubar payload AND every // connected provider's live quota. The user's intent is "make // this match reality right now." - async let payload: Void = self.refreshUsagePayloads(force: true, showLoading: true) + async let payload = self.refreshUsagePayloads( + force: true, + showLoading: true, + qualityOfService: .userInitiated + ) async let quotas: Bool = self.refreshLiveQuotaProgressIfDue(force: true) - _ = await payload + let payloadSucceeded = await payload + if payloadSucceeded { + self.recordSuccessfulUsageDataSnapshot() + } guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } self.lastRefreshTime = Date() self.refreshStatusButton() diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift index 81d049b1..fc90214d 100644 --- a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift +++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift @@ -46,7 +46,8 @@ struct DataClient { provider: ProviderFilter, includeOptimize: Bool, scope: MenubarScope = .local, - claudeConfigSourceId: String? = nil) async throws -> MenubarPayload { + claudeConfigSourceId: String? = nil, + qualityOfService: QualityOfService = .userInitiated) async throws -> MenubarPayload { let subcommand = statusSubcommand( period: period, day: day, @@ -56,7 +57,7 @@ struct DataClient { scope: scope, claudeConfigSourceId: claudeConfigSourceId ) - let result = try await runCLI(subcommand: subcommand) + let result = try await runCLI(subcommand: subcommand, qualityOfService: qualityOfService) guard result.exitCode == 0 else { throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr) } @@ -118,10 +119,13 @@ struct DataClient { /// dozens of node processes at once. private static let spawnLimiter = AsyncSemaphore(maxConcurrentSpawns) - private static func runCLI(subcommand: [String]) async throws -> ProcessResult { + private static func runCLI( + subcommand: [String], + qualityOfService: QualityOfService = .userInitiated + ) async throws -> ProcessResult { await spawnLimiter.acquire() defer { Task { await spawnLimiter.release() } } - let process = CodeburnCLI.makeProcess(subcommand: subcommand) + let process = CodeburnCLI.makeProcess(subcommand: subcommand, qualityOfService: qualityOfService) return try await runProcess(process, timeoutSeconds: spawnTimeoutSeconds, label: subcommand.joined(separator: " ")) diff --git a/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift b/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift new file mode 100644 index 00000000..dd865a95 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift @@ -0,0 +1,172 @@ +import Foundation + +struct UsageDataSnapshot: Equatable, Sendable { + let modificationDates: [String: Date] +} + +/// Cheap change detection for the background menubar usage refresh. This is +/// deliberately not a recursive session scan: a 30s timer must not replace a +/// full Node parse with a full Swift walk of the same corpus. +enum UsageDataChangeGuard { + /// Unchanged-skips are honored for at most this long after the last + /// successful fetch. The root list below tracks the CLI's provider + /// discovery by hand, so a provider missing from it must degrade to + /// "refreshes every 30 minutes", never "stale forever". + static let maxSkipIntervalSeconds: TimeInterval = 30 * 60 + + static func shouldSkip( + current: UsageDataSnapshot, + lastSuccessful: UsageDataSnapshot?, + lastSuccessAt: Date?, + now: Date = Date(), + force: Bool + ) -> Bool { + guard !force, let lastSuccessful, let lastSuccessAt else { return false } + guard now.timeIntervalSince(lastSuccessAt) < maxSkipIntervalSeconds else { return false } + return current == lastSuccessful + } + + static func snapshot( + fileManager: FileManager = .default, + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: String = NSHomeDirectory() + ) -> UsageDataSnapshot { + var dates: [String: Date] = [:] + var roots: [UsageDataRoot] = [] + + func add(_ path: String, scanFirstLevelDirectories: Bool = true) { + let root = UsageDataRoot(path: path, scanFirstLevelDirectories: scanFirstLevelDirectories) + guard !path.isEmpty, !roots.contains(root) else { return } + roots.append(root) + } + + // These are the exact configurable roots used by the menubar's CLI + // payload path. Claude's projects directories are the normal session + // roots; the desktop root is included because its project directories + // are discovered below a Claude-managed workspace hierarchy. + for configDir in claudeConfigDirectories(environment: environment, homeDirectory: homeDirectory) { + add(path(configDir, "projects")) + } + add(environment["CODEBURN_DESKTOP_SESSIONS_DIR"] ?? path(homeDirectory, "Library", "Application Support", "Claude", "local-agent-mode-sessions")) + + let codexHome = expand(environment["CODEX_HOME"] ?? path(homeDirectory, ".codex"), homeDirectory: homeDirectory) + add(path(codexHome, "sessions")) + add(path(codexHome, "archived_sessions")) + + let cursorUser = path(homeDirectory, "Library", "Application Support", "Cursor", "User") + add(path(cursorUser, "globalStorage", "state.vscdb"), scanFirstLevelDirectories: false) + add(path(cursorUser, "workspaceStorage")) + let cursorAgentHome = path(homeDirectory, ".cursor") + add(path(cursorAgentHome, "projects")) + add(path(cursorAgentHome, "ai-tracking", "ai-code-tracking.db"), scanFirstLevelDirectories: false) + + let xdgData = environment["XDG_DATA_HOME"] ?? path(homeDirectory, ".local", "share") + let xdgConfig = environment["XDG_CONFIG_HOME"] ?? path(homeDirectory, ".config") + let applicationSupport = path(homeDirectory, "Library", "Application Support") + + // Several providers use nested workspace/session layouts or SQLite + // files. Their stable top directories/files are cheap to stat, but this + // intentionally does not descend to individual transcript files; an + // in-place edit can therefore wait for the next directory-entry change + // or an interactive refresh. The tradeoff avoids a deep idle walk. + add(expand(environment["CODEWHALE_HOME"] ?? path(homeDirectory, ".codewhale"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".deepseek", "sessions"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".cline", "data"), scanFirstLevelDirectories: false) + add(expand(environment["CODEBUFF_DATA_DIR"] ?? path(xdgConfig, "manicode"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false) + let factoryHome = expand(environment["FACTORY_DIR"] ?? path(homeDirectory, ".factory"), homeDirectory: homeDirectory) + add(path(factoryHome, "sessions"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".gemini", "tmp"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".gemini", "antigravity", "conversations"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".gemini", "antigravity-cli", "conversations"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".gemini", "antigravity-cli", "implicit"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".gemini", "antigravity-ide", "conversations"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".gemini", "antigravity-ide", "implicit"), scanFirstLevelDirectories: false) + let hermesHome = expand(environment["HERMES_HOME"] ?? path(homeDirectory, ".hermes"), homeDirectory: homeDirectory) + add(hermesHome, scanFirstLevelDirectories: false) + add(path(applicationSupport, "IBM Bob", "User", "globalStorage", "ibm.bob-code"), scanFirstLevelDirectories: false) + add(path(applicationSupport, "Bob-IDE", "User", "globalStorage", "ibm.bob-code"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".kiro"), scanFirstLevelDirectories: false) + add(path(applicationSupport, "Kiro", "User", "globalStorage", "kiro.kiroagent"), scanFirstLevelDirectories: false) + add(path(applicationSupport, "Kiro", "User", "workspaceStorage"), scanFirstLevelDirectories: false) + let kimiHome = expand(environment["KIMI_SHARE_DIR"] ?? path(homeDirectory, ".kimi"), homeDirectory: homeDirectory) + add(path(kimiHome, "sessions"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".lingtai"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".lingtai-tui"), scanFirstLevelDirectories: false) + let vibeHome = expand(environment["VIBE_HOME"] ?? path(homeDirectory, ".vibe"), homeDirectory: homeDirectory) + add(path(vibeHome, "logs", "session"), scanFirstLevelDirectories: false) + let muxHome = expand(environment["CODEBURN_MUX_DIR"] ?? environment["MUX_ROOT"] ?? path(homeDirectory, ".mux"), homeDirectory: homeDirectory) + add(muxHome, scanFirstLevelDirectories: false) + for name in [".openclaw", ".clawdbot", ".moltbot", ".moldbot"] { + add(path(homeDirectory, name, "agents"), scanFirstLevelDirectories: false) + } + add(path(applicationSupport, "Open Design"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".pi", "agent", "sessions"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".omp", "agent", "sessions"), scanFirstLevelDirectories: false) + add(expand(environment["QWEN_DATA_DIR"] ?? path(homeDirectory, ".qwen", "projects"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false) + let grokHome = expand(environment["GROK_HOME"] ?? path(homeDirectory, ".grok"), homeDirectory: homeDirectory) + add(path(grokHome, "sessions"), scanFirstLevelDirectories: false) + add(expand(environment["ZS_DATA_DIR"] ?? path(applicationSupport, "zerostack"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false) + add(expand(environment["OPENCODE_DATA_DIR"] ?? path(xdgData, "opencode"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false) + add(path(xdgData, "kilo"), scanFirstLevelDirectories: false) + add(expand(environment["GOOSE_PATH_ROOT"] ?? path(xdgData, "goose"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false) + add(expand(environment["CRUSH_GLOBAL_DATA"] ?? path(xdgData, "crush"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false) + add(environment["WARP_DB_PATH"] ?? path(homeDirectory, "Library", "Group Containers", "group.warp", "Library", "Application Support", "dev.warp.Warp-Stable", "warp.sqlite"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".forge", ".forge.db"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".zcode", "cli", "db", "db.sqlite"), scanFirstLevelDirectories: false) + add(path(applicationSupport, "Zed", "threads", "threads.db"), scanFirstLevelDirectories: false) + add(path(xdgConfig, "github-copilot"), scanFirstLevelDirectories: false) + add(path(homeDirectory, ".copilot", "session-state"), scanFirstLevelDirectories: false) + add(path(applicationSupport, "Code", "User", "globalStorage", "github.copilot-chat", "agent-traces.db"), scanFirstLevelDirectories: false) + add(path(applicationSupport, "Code - Insiders", "User", "globalStorage", "github.copilot-chat", "agent-traces.db"), scanFirstLevelDirectories: false) + add(path(applicationSupport, "VSCodium", "User", "globalStorage", "github.copilot-chat", "agent-traces.db"), scanFirstLevelDirectories: false) + + // A changed menubar config can change the roots above, so keep its + // mtime in the snapshot even when the configured directory list is the + // same. Network-only providers have no local data root to fingerprint. + add(path(homeDirectory, ".config", "codeburn", "config.json"), scanFirstLevelDirectories: false) + + for root in roots { + dates[root.path] = modificationDate(atPath: root.path, fileManager: fileManager) + guard root.scanFirstLevelDirectories, + dates[root.path] != nil, + let entries = try? fileManager.contentsOfDirectory(atPath: root.path) else { continue } + for entry in entries { + let child = path(root.path, entry) + var isDirectory = ObjCBool(false) + guard fileManager.fileExists(atPath: child, isDirectory: &isDirectory), isDirectory.boolValue else { continue } + dates[child] = modificationDate(atPath: child, fileManager: fileManager) + } + } + return UsageDataSnapshot(modificationDates: dates) + } + + private struct UsageDataRoot: Hashable { + let path: String + let scanFirstLevelDirectories: Bool + } + + private static func modificationDate(atPath path: String, fileManager: FileManager) -> Date? { + guard let attributes = try? fileManager.attributesOfItem(atPath: path) else { return nil } + return attributes[.modificationDate] as? Date + } + + private static func claudeConfigDirectories(environment: [String: String], homeDirectory: String) -> [String] { + if let multi = environment["CLAUDE_CONFIG_DIRS"], !multi.isEmpty { + return multi.split(separator: ":").map { expand(String($0), homeDirectory: homeDirectory) } + } + if let single = environment["CLAUDE_CONFIG_DIR"], !single.isEmpty { + return [expand(single, homeDirectory: homeDirectory)] + } + let configured = CLIClaudeConfig.load() + return configured.isEmpty ? [path(homeDirectory, ".claude")] : configured.map { expand($0, homeDirectory: homeDirectory) } + } + + private static func expand(_ value: String, homeDirectory: String) -> String { + guard value == "~" || value.hasPrefix("~/") else { return value } + return value == "~" ? homeDirectory : path(homeDirectory, String(value.dropFirst(2))) + } + + private static func path(_ base: String, _ components: String...) -> String { + components.reduce(base) { ($0 as NSString).appendingPathComponent($1) } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/UsageRefreshCadence.swift b/mac/Sources/CodeBurnMenubar/Data/UsageRefreshCadence.swift index c2031425..ddc47ab8 100644 --- a/mac/Sources/CodeBurnMenubar/Data/UsageRefreshCadence.swift +++ b/mac/Sources/CodeBurnMenubar/Data/UsageRefreshCadence.swift @@ -1,8 +1,9 @@ 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` keeps the adaptive default: 30s while active, 120s while closed on AC, +/// backed off further 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. @@ -17,7 +18,7 @@ enum UsageRefreshCadence: Int, CaseIterable, Identifiable { var label: String { switch self { - case .auto: return "Auto (30s, less on battery)" + case .auto: return "Auto (2m, less on battery)" case .manual: return "Manual" case .oneMinute: return "1 minute" case .fiveMinutes: return "5 minutes" diff --git a/mac/Sources/CodeBurnMenubar/RefreshCadence.swift b/mac/Sources/CodeBurnMenubar/RefreshCadence.swift index 0cf532ca..432b99e0 100644 --- a/mac/Sources/CodeBurnMenubar/RefreshCadence.swift +++ b/mac/Sources/CodeBurnMenubar/RefreshCadence.swift @@ -4,12 +4,13 @@ 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 closed nobody is looking at anything but the status figure, so AC +/// uses a 120s minimum and battery or Low Power Mode backs off further. 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 acIdleSeconds: TimeInterval = 120 static let batteryIdleSeconds: TimeInterval = 150 static let lowPowerIdleSeconds: TimeInterval = 300 @@ -28,7 +29,7 @@ enum RefreshCadence { if popoverOpen { return activeSeconds } if lowPowerMode { return lowPowerIdleSeconds } if onBattery { return batteryIdleSeconds } - return activeSeconds + return acIdleSeconds 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. diff --git a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift index 82d6e265..0a644d09 100644 --- a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift +++ b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift @@ -82,7 +82,10 @@ enum CodeburnCLI { /// 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 { + static func makeProcess( + subcommand: [String], + qualityOfService: QualityOfService = .userInitiated + ) -> Process { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") var environment = ProcessInfo.processInfo.environment @@ -95,7 +98,7 @@ enum CodeburnCLI { // 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 + process.qualityOfService = qualityOfService return process } diff --git a/mac/Tests/CodeBurnMenubarTests/RefreshCadenceTests.swift b/mac/Tests/CodeBurnMenubarTests/RefreshCadenceTests.swift index ef27985c..b96e917b 100644 --- a/mac/Tests/CodeBurnMenubarTests/RefreshCadenceTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/RefreshCadenceTests.swift @@ -9,10 +9,10 @@ final class RefreshCadenceTests: XCTestCase { ) } - func testAutoIdleOnACStaysActive() { + func testAutoIdleOnACUsesTwoMinuteMinimum() { XCTAssertEqual( RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: false, lowPowerMode: false), - RefreshCadence.activeSeconds + 120 ) } diff --git a/mac/Tests/CodeBurnMenubarTests/UsageDataChangeGuardTests.swift b/mac/Tests/CodeBurnMenubarTests/UsageDataChangeGuardTests.swift new file mode 100644 index 00000000..2d9528c6 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/UsageDataChangeGuardTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("Usage data change guard") +struct UsageDataChangeGuardTests { + private let now = Date(timeIntervalSince1970: 1_000_000) + + @Test("fresh snapshot skips") + func freshSnapshotSkips() { + let snapshot = makeSnapshot(10) + #expect(UsageDataChangeGuard.shouldSkip( + current: snapshot, + lastSuccessful: snapshot, + lastSuccessAt: now, + now: now, + force: false + )) + } + + @Test("stale snapshot does not skip") + func staleSnapshotDoesNotSkip() { + #expect(!UsageDataChangeGuard.shouldSkip( + current: makeSnapshot(20), + lastSuccessful: makeSnapshot(10), + lastSuccessAt: now, + now: now, + force: false + )) + } + + @Test("first run does not skip") + func firstRunDoesNotSkip() { + #expect(!UsageDataChangeGuard.shouldSkip( + current: makeSnapshot(10), + lastSuccessful: nil, + lastSuccessAt: nil, + now: now, + force: false + )) + } + + @Test("force refresh bypasses fresh snapshot") + func forceRefreshBypassesFreshSnapshot() { + let snapshot = makeSnapshot(10) + #expect(!UsageDataChangeGuard.shouldSkip( + current: snapshot, + lastSuccessful: snapshot, + lastSuccessAt: now, + now: now, + force: true + )) + } + + @Test("unchanged snapshot stops skipping after the backstop interval") + func backstopForcesRefreshAfterMaxSkipInterval() { + let snapshot = makeSnapshot(10) + let justInside = now.addingTimeInterval(UsageDataChangeGuard.maxSkipIntervalSeconds - 1) + let atBoundary = now.addingTimeInterval(UsageDataChangeGuard.maxSkipIntervalSeconds) + #expect(UsageDataChangeGuard.shouldSkip( + current: snapshot, + lastSuccessful: snapshot, + lastSuccessAt: now, + now: justInside, + force: false + )) + #expect(!UsageDataChangeGuard.shouldSkip( + current: snapshot, + lastSuccessful: snapshot, + lastSuccessAt: now, + now: atBoundary, + force: false + )) + } + + private func makeSnapshot(_ seconds: TimeInterval) -> UsageDataSnapshot { + UsageDataSnapshot(modificationDates: ["provider-root": Date(timeIntervalSince1970: seconds)]) + } +}