diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 29ad39c..a58d044 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -6,6 +6,7 @@ private let refreshIntervalSeconds: UInt64 = 30 private let nanosPerSecond: UInt64 = 1_000_000_000 private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond private let forceRefreshWatchdogSeconds: TimeInterval = 90 +private let interactiveQuotaRefreshFloorSeconds: TimeInterval = 30 private let statusItemWidth: CGFloat = NSStatusItem.variableLength private let popoverWidth: CGFloat = 360 private let popoverHeight: CGFloat = 660 @@ -262,7 +263,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { forceRefreshTask = Task { async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true) async let today: Void = store.refreshQuietly(period: .today) - _ = await (main, today) + async let quotas: Bool = refreshLiveQuotaProgressIfDue() + _ = await (main, today, quotas) refreshStatusButton() await MainActor.run { [weak self] in guard let self, self.forceRefreshGeneration == generation else { return } @@ -296,6 +298,51 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } fileprivate var lastSubscriptionRefreshAt: Date? + fileprivate var lastCodexRefreshAt: Date? + + @discardableResult + private func refreshLiveQuotaProgressIfDue(force: Bool = false) async -> Bool { + let cadence = SubscriptionRefreshCadence.current + if !force && cadence == .manual { return false } + + let now = Date() + let threshold = force ? 0 : TimeInterval(cadence.rawValue) + let shouldRefreshClaude = force || now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) >= threshold + let shouldRefreshCodex = force || now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast) >= threshold + guard shouldRefreshClaude || shouldRefreshCodex else { return false } + + switch (shouldRefreshClaude, shouldRefreshCodex) { + case (true, true): + async let claude = store.refreshSubscriptionReportingSuccess() + async let codex = store.refreshCodexReportingSuccess() + if await claude { lastSubscriptionRefreshAt = Date() } + if await codex { lastCodexRefreshAt = Date() } + case (true, false): + if await store.refreshSubscriptionReportingSuccess() { + lastSubscriptionRefreshAt = Date() + } + case (false, true): + if await store.refreshCodexReportingSuccess() { + lastCodexRefreshAt = Date() + } + case (false, false): + break + } + return true + } + + private func refreshLiveQuotaProgressForPopoverOpen() { + let now = Date() + let claudeElapsed = now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) + let codexElapsed = now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast) + guard claudeElapsed >= interactiveQuotaRefreshFloorSeconds || + codexElapsed >= interactiveQuotaRefreshFloorSeconds else { return } + + Task { [weak self] in + guard let self else { return } + _ = await self.refreshLiveQuotaProgressIfDue(force: true) + } + } private func startRefreshLoop() { refreshLoopTask?.cancel() @@ -303,10 +350,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // Provider refreshes only run when the user has explicitly connected. // Each refresh is a no-op until its corresponding bootstrap flag is set. if let self { - async let claude = self.store.refreshSubscriptionReportingSuccess() - async let codex = self.store.refreshCodexReportingSuccess() - if await claude { self.lastSubscriptionRefreshAt = Date() } - if await codex { self.lastCodexRefreshAt = Date() } + await self.refreshLiveQuotaProgressIfDue(force: true) } while !Task.isCancelled { guard let self else { return } @@ -332,26 +376,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // (not last attempt) so an intermittent failure doesn't reset // the timer. Each provider has its own anchor so a Codex 429 // doesn't delay a due Claude refresh. - let cadence = SubscriptionRefreshCadence.current - if cadence != .manual { - let claudeElapsed = Date().timeIntervalSince(self.lastSubscriptionRefreshAt ?? .distantPast) - if claudeElapsed >= TimeInterval(cadence.rawValue) { - let succeeded = await self.store.refreshSubscriptionReportingSuccess() - if succeeded { self.lastSubscriptionRefreshAt = Date() } - } - let codexElapsed = Date().timeIntervalSince(self.lastCodexRefreshAt ?? .distantPast) - if codexElapsed >= TimeInterval(cadence.rawValue) { - let succeeded = await self.store.refreshCodexReportingSuccess() - if succeeded { self.lastCodexRefreshAt = Date() } - } - } + await self.refreshLiveQuotaProgressIfDue() try? await Task.sleep(nanoseconds: refreshIntervalNanos) } } } - fileprivate var lastCodexRefreshAt: Date? - @MainActor func refreshSubscriptionNow() { manualRefreshTask?.cancel() @@ -376,8 +406,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // this match reality right now." let needsTodayTotal = self.store.selectedPeriod != .today || self.store.selectedProvider != .all async let payload: Void = self.store.refresh(includeOptimize: false, force: true, showLoading: true) - async let claude: Bool = self.store.refreshSubscriptionReportingSuccess() - async let codex: Bool = self.store.refreshCodexReportingSuccess() + async let quotas: Bool = self.refreshLiveQuotaProgressIfDue(force: true) if needsTodayTotal { await self.store.refreshQuietly(period: .today) } @@ -385,8 +414,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } self.lastRefreshTime = Date() self.refreshStatusButton() - if await claude { self.lastSubscriptionRefreshAt = Date() } - if await codex { self.lastCodexRefreshAt = Date() } + _ = await quotas guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } self.manualRefreshTask = nil if self.refreshLoopTask == nil { @@ -589,6 +617,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { window.collectionBehavior.insert(.canJoinAllSpaces) window.makeKeyAndOrderFront(nil) } + refreshLiveQuotaProgressForPopoverOpen() } }