import Foundation /// Wiring around `AdaptiveRefreshPolicy` for `UsageStore.startTimer()`: gathering live signals, /// logging the resulting decision, and applying the DEBUG-only sleep-duration override used by /// tests. Split out of UsageStore.swift to keep that file's class body under the lint line limit. extension UsageStore { func effectiveTimerSleepDuration(_ computed: Duration) -> Duration { #if DEBUG self.refreshTimerSleepOverrideForTesting ?? computed #else computed #endif } /// Pure wiring helper: builds the `AdaptiveRefreshPolicy.Input` from explicit values and /// returns the resulting decision. `startTimer()` supplies live `ProcessInfo` state and /// `lastMenuOpenAt` at call time; this stays a plain, testable function of its arguments. nonisolated static func adaptiveRefreshDecision( now: Date, lastMenuOpenAt: Date?, lowPowerModeEnabled: Bool, thermalState: ProcessInfo.ThermalState, policy: AdaptiveRefreshPolicy = AdaptiveRefreshPolicy()) -> AdaptiveRefreshPolicy.Decision { policy.nextDelay(for: AdaptiveRefreshPolicy.Input( now: now, lastMenuOpenAt: lastMenuOpenAt, lowPowerModeEnabled: lowPowerModeEnabled, thermalState: thermalState)) } nonisolated static func shouldAdvanceAdaptiveTimer(scheduledAt: Date?, candidate: Date) -> Bool { guard let scheduledAt else { return true } return candidate < scheduledAt } /// Advances a fixed timer from the last scheduled tick instead of the refresh completion time. /// Missed ticks are skipped so a refresh that runs longer than its interval does not create /// overlapping catch-up refreshes. nonisolated static func nextFixedTimerScheduledAt( previousScheduledAt: ContinuousClock.Instant, completedAt: ContinuousClock.Instant, interval: Duration) -> ContinuousClock.Instant { precondition(interval > .zero) var scheduledAt = previousScheduledAt + interval while scheduledAt <= completedAt { scheduledAt += interval } return scheduledAt } nonisolated static func runFixedRefreshTimer( interval: Duration, sleepOverride: Duration? = nil, now: @escaping @Sendable () async -> ContinuousClock.Instant = { ContinuousClock.now }, sleep: @escaping @Sendable (Duration) async throws -> Void = { duration in try await Task.sleep(for: duration) }, refresh: @escaping @Sendable () async -> Void) async { precondition(interval > .zero) var scheduledAt = await now() + interval while !Task.isCancelled { let current = await now() let computedSleep = current >= scheduledAt ? .zero : scheduledAt - current do { try await sleep(sleepOverride ?? computedSleep) } catch { return } guard !Task.isCancelled else { return } await refresh() scheduledAt = await self.nextFixedTimerScheduledAt( previousScheduledAt: scheduledAt, completedAt: now(), interval: interval) } } func logAdaptiveRefreshDecision(_ decision: AdaptiveRefreshPolicy.Decision) { // Reason and delay only; never provider/account/email/path/credential/response data. // No "adaptive refresh: " prefix — the adaptiveRefresh log category already identifies the source. self.adaptiveRefreshLogger.debug( "reason=\(decision.reason.rawValue) delay=\(decision.delay.components.seconds)s") } /// Computes this tick's adaptive sleep duration (and logs the decision) while briefly holding a /// strong reference to `store`; returns nil once the store has deallocated, ending the loop. /// Kept as a separate call so the strong reference doesn't extend into the caller's `Task.sleep`. static func nextAdaptiveTimerSleepDuration(for store: UsageStore?) async -> Duration? { guard let store else { return nil } let now = Date() let decision = Self.adaptiveRefreshDecision( now: now, lastMenuOpenAt: store.lastMenuOpenAt, lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, thermalState: ProcessInfo.processInfo.thermalState) store.adaptiveRefreshScheduledAt = now.addingTimeInterval(TimeInterval(decision.delay.components.seconds)) store.logAdaptiveRefreshDecision(decision) return store.effectiveTimerSleepDuration(decision.delay) } /// The refresh interval scheduling *heuristics* (reset-boundary refresh, OpenAI web staleness, /// persistent-CLI-session idle windows) should use as "how often does a normal refresh happen". /// This is deliberately distinct from `RefreshFrequency.seconds`, which is nil for both `.manual` /// (no timer at all — heuristics correctly get nil here too) and `.adaptive` (no *fixed* /// interval, but ticks are still happening on a real, computable cadence). For `.adaptive`, this /// resolves to what `AdaptiveRefreshPolicy` would decide right now from live signals, so those /// heuristics stay active and roughly proportionate instead of silently behaving like manual. func normalRefreshIntervalForHeuristics() -> TimeInterval? { switch self.settings.refreshFrequency { case .manual: nil case .adaptive: TimeInterval(Self.adaptiveRefreshDecision( now: Date(), lastMenuOpenAt: self.lastMenuOpenAt, lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, thermalState: ProcessInfo.processInfo.thermalState).delay.components.seconds) default: self.settings.refreshFrequency.seconds } } }