diff --git a/CHANGELOG.md b/CHANGELOG.md index f2de4d7f8d..482cc7f69b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.46.1 — Unreleased ### Added +- Refresh: add a default-off global Low Power Mode that limits automatic provider, local usage, and storage work to once every 30 minutes while keeping manual refresh immediate (#2518). Thanks @Carl723000! - Claude: compact multi-account menu for claude-swap — with four or more accounts the active account keeps its full card while the others become one-line rows sorted by remaining headroom, constrained accounts surface in red/amber, the healthiest switch target gets a star, and the healthy tail folds behind a summary row. Click a row to expand its full card. - Menu: the compact multi-account layout now covers every stacked multi-account list — token accounts on any provider and Codex accounts (flat lists; workspace-grouped Codex lists keep their sections). diff --git a/Sources/CodexBar/BackgroundWorkPowerPolicy.swift b/Sources/CodexBar/BackgroundWorkPowerPolicy.swift new file mode 100644 index 0000000000..4388554d47 --- /dev/null +++ b/Sources/CodexBar/BackgroundWorkPowerPolicy.swift @@ -0,0 +1,14 @@ +import Foundation + +enum BackgroundWorkPowerPolicy { + static let lowPowerMinimumInterval: TimeInterval = 30 * 60 + + static func automaticInterval( + _ requested: TimeInterval?, + lowPowerModeEnabled: Bool) -> TimeInterval? + { + guard let requested else { return nil } + guard lowPowerModeEnabled else { return requested } + return max(requested, self.lowPowerMinimumInterval) + } +} diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 4b2b0e750d..db9f290da2 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -174,6 +174,14 @@ struct GeneralPane: View { Toggle(L("refresh_on_open_title"), isOn: self.$settings.refreshAllProvidersOnMenuOpen) + Toggle(isOn: self.$settings.backgroundWorkLowPowerModeEnabled) { + SettingsRowLabel( + L("Low Power Mode"), + subtitle: L( + "Runs automatic provider, local usage, and storage refreshes no more often than every " + + "30 minutes. Manual refresh remains available.")) + } + Toggle(isOn: self.$settings.statusChecksEnabled) { SettingsRowLabel( L("check_provider_status_title"), diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 5744287c09..322a15637d 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -134,7 +134,7 @@ struct CodexProviderImplementation: ProviderImplementation { onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-openai-web-battery-saver", - title: "Battery Saver", + title: "OpenAI web battery saver", subtitle: [ "Limits background chatgpt.com refreshes to reduce battery and network usage.", "Dashboard extras may stay stale until you refresh them manually.", diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 06fc0d4755..9cb5984099 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -193,6 +193,7 @@ "Login failed" = "登录失败"; "Login shell PATH (startup capture)" = "登录 shell PATH(启动时捕获)"; "Login timed out" = "登录超时"; +"Low Power Mode" = "低功耗模式"; "MCP details" = "MCP 详情"; "Managed Codex accounts unavailable" = "托管 Codex 账户不可用"; "Managed account storage is unreadable. Live account access is still available, " = "托管账户存储不可读。实时账户访问仍可用,"; @@ -244,6 +245,7 @@ "Open menu" = "打开菜单"; "Open token file" = "打开令牌文件"; "OpenAI cookies" = "OpenAI Cookie"; +"OpenAI web battery saver" = "OpenAI 网页省电"; "OpenAI web extras" = "OpenAI Web 附加功能"; "Option A" = "选项 A"; "Option B" = "选项 B"; @@ -278,6 +280,7 @@ "Quit CodexBar" = "退出 CodexBar"; "Random (default)" = "随机(默认)"; "Reads local usage logs. Shows today + last 30 days cost in the menu." = "读取本地用量日志。在菜单中显示今天及所选历史窗口的费用。"; +"Runs automatic provider, local usage, and storage refreshes no more often than every 30 minutes. Manual refresh remains available." = "自动供应商刷新、本地用量扫描和存储扫描最多每 30 分钟运行一次;仍可随时手动刷新。"; "Refresh" = "刷新"; "Refreshing" = "正在刷新"; "Refresh cadence" = "刷新频率"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index c051d5b39b..78f887fc1e 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -704,6 +704,22 @@ extension SettingsStore { } } + var backgroundWorkLowPowerModeEnabled: Bool { + get { self.defaultsState.backgroundWorkLowPowerModeEnabled } + set { + self.defaultsState.backgroundWorkLowPowerModeEnabled = newValue + self.userDefaults.set(newValue, forKey: "backgroundWorkLowPowerModeEnabled") + CodexBarLog.logger(LogCategories.settings).info( + "Background work low power mode updated", + metadata: ["enabled": newValue ? "1" : "0"]) + self.noteBackgroundWorkSettingsChanged() + } + } + + var effectiveOpenAIWebBatterySaverEnabled: Bool { + self.openAIWebBatterySaverEnabled || self.backgroundWorkLowPowerModeEnabled + } + var providerStorageFootprintsEnabled: Bool { get { self.defaultsState.providerStorageFootprintsEnabled } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index f4ecdee6ff..7ad77e4f91 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -60,6 +60,7 @@ extension SettingsStore { _ = self.codexSparkUsageVisible _ = self.openAIWebAccessEnabled _ = self.openAIWebBatterySaverEnabled + _ = self.backgroundWorkLowPowerModeEnabled _ = self.providerStorageFootprintsEnabled _ = self.agentSessionsEnabled _ = self.agentSessionLabelStyle diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index f453205888..ee271e417e 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -509,6 +509,8 @@ extension SettingsStore { if Self.isRunningTests, openAIWebBatterySaverDefault == nil { userDefaults.set(false, forKey: "openAIWebBatterySaverEnabled") } + let backgroundWorkLowPowerModeEnabled = + userDefaults.object(forKey: "backgroundWorkLowPowerModeEnabled") as? Bool ?? false let providerStorageFootprintsDefault = userDefaults.object(forKey: "providerStorageFootprintsEnabled") as? Bool let providerStorageFootprintsEnabled = providerStorageFootprintsDefault ?? false if Self.isRunningTests, providerStorageFootprintsDefault == nil { @@ -591,6 +593,7 @@ extension SettingsStore { codexSparkUsageVisible: codexSparkUsageVisible, openAIWebAccessEnabled: openAIWebAccessEnabled, openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled, + backgroundWorkLowPowerModeEnabled: backgroundWorkLowPowerModeEnabled, providerStorageFootprintsEnabled: providerStorageFootprintsEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, mergeIcons: mergeIcons, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 9f72aeaaa0..26b6ce2812 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -60,6 +60,7 @@ struct SettingsDefaultsState { var codexSparkUsageVisible: Bool var openAIWebAccessEnabled: Bool var openAIWebBatterySaverEnabled: Bool + var backgroundWorkLowPowerModeEnabled: Bool var providerStorageFootprintsEnabled: Bool var jetbrainsIDEBasePath: String var mergeIcons: Bool diff --git a/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift b/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift index 20e29a700a..668df78c20 100644 --- a/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift +++ b/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift @@ -4,6 +4,15 @@ import Foundation /// 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 { + nonisolated static func effectiveAutomaticRefreshInterval( + _ requested: TimeInterval?, + lowPowerModeEnabled: Bool) -> TimeInterval? + { + BackgroundWorkPowerPolicy.automaticInterval( + requested, + lowPowerModeEnabled: lowPowerModeEnabled) + } + func effectiveTimerSleepDuration(_ computed: Duration) -> Duration { #if DEBUG self.refreshTimerSleepOverrideForTesting ?? computed @@ -50,7 +59,11 @@ extension UsageStore { lastCodingActivityAt: self.settings.adaptiveActivityScanningEnabled ? self.lastCodingActivityAt : nil, lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, thermalState: ProcessInfo.processInfo.thermalState) - let candidate = date.addingTimeInterval(TimeInterval(decision.delay.components.seconds)) + let requestedDelay = TimeInterval(decision.delay.components.seconds) + let effectiveDelay = Self.effectiveAutomaticRefreshInterval( + requestedDelay, + lowPowerModeEnabled: self.settings.backgroundWorkLowPowerModeEnabled) ?? requestedDelay + let candidate = date.addingTimeInterval(effectiveDelay) guard Self.shouldAdvanceAdaptiveTimer( scheduledAt: self.adaptiveRefreshScheduledAt, candidate: candidate) @@ -123,9 +136,16 @@ extension UsageStore { : nil, lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, thermalState: ProcessInfo.processInfo.thermalState) - store.adaptiveRefreshScheduledAt = now.addingTimeInterval(TimeInterval(decision.delay.components.seconds)) + let requestedDelay = TimeInterval(decision.delay.components.seconds) + let effectiveDelay = Self.effectiveAutomaticRefreshInterval( + requestedDelay, + lowPowerModeEnabled: store.settings.backgroundWorkLowPowerModeEnabled) ?? requestedDelay + #if DEBUG + store.adaptiveRefreshComputedIntervalForTesting = effectiveDelay + #endif + store.adaptiveRefreshScheduledAt = now.addingTimeInterval(effectiveDelay) store.logAdaptiveRefreshDecision(decision) - return store.effectiveTimerSleepDuration(decision.delay) + return store.effectiveTimerSleepDuration(.seconds(effectiveDelay)) } /// The refresh interval scheduling *heuristics* (reset-boundary refresh, OpenAI web staleness, @@ -136,7 +156,7 @@ extension UsageStore { /// 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 { + let requested: TimeInterval? = switch self.settings.refreshFrequency { case .manual: nil case .adaptive, .adaptiveAgentAware: @@ -151,5 +171,8 @@ extension UsageStore { default: self.settings.refreshFrequency.seconds } + return Self.effectiveAutomaticRefreshInterval( + requested, + lowPowerModeEnabled: self.settings.backgroundWorkLowPowerModeEnabled) } } diff --git a/Sources/CodexBar/UsageStore+Logging.swift b/Sources/CodexBar/UsageStore+Logging.swift index 7b4ff0826b..87e801fbd6 100644 --- a/Sources/CodexBar/UsageStore+Logging.swift +++ b/Sources/CodexBar/UsageStore+Logging.swift @@ -20,6 +20,8 @@ extension UsageStore { "ollamaCookieSource": self.settings.ollamaCookieSource.rawValue, "openAIWebAccess": self.settings.openAIWebAccessEnabled ? "1" : "0", "openAIWebBatterySaver": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", + "backgroundWorkLowPowerMode": self.settings.backgroundWorkLowPowerModeEnabled ? "1" : "0", + "effectiveOpenAIWebBatterySaver": self.settings.effectiveOpenAIWebBatterySaverEnabled ? "1" : "0", "claudeWebExtras": self.settings.claudeWebExtrasEnabled ? "1" : "0", "kiloExtras": self.settings.kiloExtrasEnabled ? "1" : "0", ] diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 08e94642d3..063218eada 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -99,13 +99,13 @@ extension UsageStore { let stamp = now.formatted(date: .abbreviated, time: .shortened) self.logOpenAIWeb("[\(stamp)] OpenAI web refresh request: \(reason)") let forceRefresh = Self.forceOpenAIWebRefreshForStaleRequest( - batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled) || needsMenuHistoryRefresh + batterySaverEnabled: self.settings.effectiveOpenAIWebBatterySaverEnabled) || needsMenuHistoryRefresh self.openAIWebLogger.info( "OpenAI web stale refresh gate", metadata: [ "reason": reason, "force": forceRefresh ? "1" : "0", - "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", + "batterySaverEnabled": self.settings.effectiveOpenAIWebBatterySaverEnabled ? "1" : "0", "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", ]) let expectedGuard = self.freshCodexOpenAIWebRefreshGuard() diff --git a/Sources/CodexBar/UsageStore+ProviderStorage.swift b/Sources/CodexBar/UsageStore+ProviderStorage.swift index 826ab19907..58cf07338f 100644 --- a/Sources/CodexBar/UsageStore+ProviderStorage.swift +++ b/Sources/CodexBar/UsageStore+ProviderStorage.swift @@ -9,7 +9,11 @@ extension UsageStore { let signature: String } - private static let automaticStorageRefreshInterval: TimeInterval = 5 * 60 + nonisolated static func automaticStorageRefreshInterval(lowPowerModeEnabled: Bool) -> TimeInterval { + BackgroundWorkPowerPolicy.automaticInterval( + 5 * 60, + lowPowerModeEnabled: lowPowerModeEnabled) ?? 5 * 60 + } var isStorageRefreshInFlight: Bool { self.storageRefreshTask != nil @@ -102,7 +106,8 @@ extension UsageStore { if !force { if self.lastStorageRefreshRequestKey == requestKey, let lastStorageRefreshAt, - now.timeIntervalSince(lastStorageRefreshAt) < Self.automaticStorageRefreshInterval + now.timeIntervalSince(lastStorageRefreshAt) < Self.automaticStorageRefreshInterval( + lowPowerModeEnabled: self.settings.backgroundWorkLowPowerModeEnabled) { return } diff --git a/Sources/CodexBar/UsageStore+RefreshEnrichment.swift b/Sources/CodexBar/UsageStore+RefreshEnrichment.swift index c755db1dcd..c438c6b50c 100644 --- a/Sources/CodexBar/UsageStore+RefreshEnrichment.swift +++ b/Sources/CodexBar/UsageStore+RefreshEnrichment.swift @@ -296,7 +296,7 @@ extension UsageStore { accessEnabled: self.isEnabled(.codex) && self.settings.openAIWebAccessEnabled && self.settings.codexCookieSource.isEnabled, - batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled, + batterySaverEnabled: self.settings.effectiveOpenAIWebBatterySaverEnabled, force: force, refreshPhase: refreshPhase) let shouldRefreshOpenAIWeb = Self.shouldRunOpenAIWebRefresh(refreshPolicy) diff --git a/Sources/CodexBar/UsageStore+ResetBoundaryRefresh.swift b/Sources/CodexBar/UsageStore+ResetBoundaryRefresh.swift index bd447f88ce..60d41ad21a 100644 --- a/Sources/CodexBar/UsageStore+ResetBoundaryRefresh.swift +++ b/Sources/CodexBar/UsageStore+ResetBoundaryRefresh.swift @@ -11,9 +11,13 @@ extension UsageStore { normalRefreshInterval: TimeInterval?, now: Date = Date()) { + let minimumAutomaticRefreshInterval = self.settings.backgroundWorkLowPowerModeEnabled + ? BackgroundWorkPowerPolicy.lowPowerMinimumInterval + : nil guard let candidate = Self.nextResetBoundaryRefreshCandidate( snapshots: self.snapshots, normalRefreshInterval: normalRefreshInterval, + minimumAutomaticRefreshInterval: minimumAutomaticRefreshInterval, attemptedBoundaryRefreshes: self.attemptedResetBoundaryRefreshes, now: now) else { @@ -67,6 +71,7 @@ extension UsageStore { nonisolated static func nextResetBoundaryRefreshDate( snapshots: [UsageProvider: UsageSnapshot], normalRefreshInterval: TimeInterval?, + minimumAutomaticRefreshInterval: TimeInterval? = nil, attemptedBoundaryRefreshes: Set = [], now: Date) -> Date? @@ -74,6 +79,7 @@ extension UsageStore { self.nextResetBoundaryRefreshCandidate( snapshots: snapshots, normalRefreshInterval: normalRefreshInterval, + minimumAutomaticRefreshInterval: minimumAutomaticRefreshInterval, attemptedBoundaryRefreshes: attemptedBoundaryRefreshes, now: now)? .refreshAt @@ -86,18 +92,21 @@ extension UsageStore { private nonisolated static func nextResetBoundaryRefreshCandidate( snapshots: [UsageProvider: UsageSnapshot], normalRefreshInterval: TimeInterval?, + minimumAutomaticRefreshInterval: TimeInterval?, attemptedBoundaryRefreshes: Set = [], now: Date) -> ResetBoundaryRefreshCandidate? { guard let normalRefreshInterval else { return nil } let normalRefreshDate = now.addingTimeInterval(normalRefreshInterval) + let earliestAutomaticRefreshDate = minimumAutomaticRefreshInterval.map(now.addingTimeInterval) return snapshots.values .flatMap { snapshot in Self.resetBoundaryRefreshCandidates( snapshot: snapshot, now: now, normalRefreshDate: normalRefreshDate, + earliestAutomaticRefreshDate: earliestAutomaticRefreshDate, attemptedBoundaryRefreshes: attemptedBoundaryRefreshes) } .min { $0.refreshAt < $1.refreshAt } @@ -107,6 +116,7 @@ extension UsageStore { snapshot: UsageSnapshot, now: Date, normalRefreshDate: Date, + earliestAutomaticRefreshDate: Date?, attemptedBoundaryRefreshes: Set) -> [ResetBoundaryRefreshCandidate] { @@ -116,10 +126,14 @@ extension UsageStore { guard !attemptedBoundaryRefreshes.contains(boundaryRefreshAt) else { return nil } guard boundaryRefreshAt <= normalRefreshDate else { return nil } guard snapshot.updatedAt < boundaryRefreshAt else { return nil } + let minimumDelayRefreshAt = now.addingTimeInterval(Self.resetBoundaryRefreshMinimumDelaySeconds) + let earliestAllowedRefreshAt = max( + minimumDelayRefreshAt, + earliestAutomaticRefreshDate ?? minimumDelayRefreshAt) + let refreshAt = max(boundaryRefreshAt, earliestAllowedRefreshAt) + guard refreshAt <= normalRefreshDate else { return nil } return ResetBoundaryRefreshCandidate( - refreshAt: max( - boundaryRefreshAt, - now.addingTimeInterval(Self.resetBoundaryRefreshMinimumDelaySeconds)), + refreshAt: refreshAt, boundaryRefreshAt: boundaryRefreshAt) } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 19c9488717..fb04240a74 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -379,14 +379,22 @@ final class UsageStore { static let minimumTokenFetchTTL: TimeInterval = 5 * 60 var tokenFetchTTL: TimeInterval? { - Self.tokenFetchTTL(for: self.settings.refreshFrequency) + Self.tokenFetchTTL( + for: self.settings.refreshFrequency, + lowPowerModeEnabled: self.settings.backgroundWorkLowPowerModeEnabled) } - static func tokenFetchTTL(for frequency: RefreshFrequency) -> TimeInterval? { + static func tokenFetchTTL( + for frequency: RefreshFrequency, + lowPowerModeEnabled: Bool = false) -> TimeInterval? + { let interval = frequency.usesAdaptivePolicy ? AdaptiveRefreshPolicy.nominalIntervalForHeuristics : frequency.seconds - return interval.map { max($0, Self.minimumTokenFetchTTL) } + let widgetSafeInterval = interval.map { max($0, Self.minimumTokenFetchTTL) } + return BackgroundWorkPowerPolicy.automaticInterval( + widgetSafeInterval, + lowPowerModeEnabled: lowPowerModeEnabled) } @ObservationIgnored let tokenFetchTimeout: TimeInterval = 10 * 60 @@ -783,6 +791,8 @@ final class UsageStore { #if DEBUG @ObservationIgnored private(set) var refreshTimerSleepOverrideForTesting: Duration? + @ObservationIgnored private(set) var fixedRefreshIntervalForTesting: TimeInterval? + @ObservationIgnored var adaptiveRefreshComputedIntervalForTesting: TimeInterval? /// Sets this store's timer sleep override and restarts the timer with it applied, so tests can /// observe multiple fixed/adaptive ticks without waiting real minutes. The reason/delay a tick @@ -798,6 +808,10 @@ final class UsageStore { private func startTimer(preservingResetBoundaryRefresh: Bool = false) { self.timerTask?.cancel() self.adaptiveRefreshScheduledAt = nil + #if DEBUG + self.fixedRefreshIntervalForTesting = nil + self.adaptiveRefreshComputedIntervalForTesting = nil + #endif if !preservingResetBoundaryRefresh { self.cancelResetBoundaryRefresh() } @@ -822,8 +836,12 @@ final class UsageStore { return } - guard let wait = frequency.seconds else { return } + guard let wait = Self.effectiveAutomaticRefreshInterval( + frequency.seconds, + lowPowerModeEnabled: self.settings.backgroundWorkLowPowerModeEnabled) + else { return } #if DEBUG + self.fixedRefreshIntervalForTesting = wait let fixedTimerSleepOverride = self.refreshTimerSleepOverrideForTesting #else let fixedTimerSleepOverride: Duration? = nil diff --git a/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift b/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift index b60601963c..be1bc213be 100644 --- a/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift +++ b/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift @@ -32,6 +32,22 @@ struct AdaptiveRefreshHeuristicsTests { #expect(store.normalRefreshIntervalForHeuristics() == expectedSeconds) } + @Test + func `global low power mode clamps fixed and interactive adaptive heuristics`() { + let fixedStore = Self.makeStore(suite: "heuristics-low-power-fixed", frequency: .oneMinute) + fixedStore.settings.backgroundWorkLowPowerModeEnabled = true + #expect(fixedStore.normalRefreshIntervalForHeuristics() == 1800.0) + + let adaptiveStore = Self.makeStore(suite: "heuristics-low-power-adaptive", frequency: .adaptive) + adaptiveStore.settings.backgroundWorkLowPowerModeEnabled = true + adaptiveStore.noteMenuOpened() + #expect(adaptiveStore.normalRefreshIntervalForHeuristics() == 1800.0) + + let manualStore = Self.makeStore(suite: "heuristics-low-power-manual", frequency: .manual) + manualStore.settings.backgroundWorkLowPowerModeEnabled = true + #expect(manualStore.normalRefreshIntervalForHeuristics() == nil) + } + @Test func `adaptive resolves to the live adaptive decision delay`() { let store = Self.makeStore(suite: "heuristics-adaptive-live", frequency: .adaptive) diff --git a/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift b/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift index 6bb5828436..65265465f0 100644 --- a/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift +++ b/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift @@ -210,6 +210,42 @@ struct AdaptiveRefreshTimerTests { #expect(store.completedRefreshCountForTesting >= 2) } + @Test + func `fixed timer uses global low power interval without changing test sleep override`() throws { + let settings = Self.makeSettingsStore( + suite: "AdaptiveRefreshTimerTests-fixed-global-low-power", + frequency: .fiveMinutes) + settings.backgroundWorkLowPowerModeEnabled = true + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + + store.restartTimerWithSleepOverrideForTesting(.seconds(10)) + + let computedInterval = try #require(store.fixedRefreshIntervalForTesting) + #expect(computedInterval == 30 * 60) + #expect(store.refreshTimerSleepOverrideForTesting == .seconds(10)) + } + + @Test + func `adaptive timer publishes clamped schedule while preserving test sleep override`() async throws { + let settings = Self.makeSettingsStore( + suite: "AdaptiveRefreshTimerTests-adaptive-global-low-power", + frequency: .adaptive) + settings.backgroundWorkLowPowerModeEnabled = true + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + let now = Date() + store.noteMenuOpened(at: now.addingTimeInterval(-10 * 60)) + store.restartTimerWithSleepOverrideForTesting(.seconds(10)) + + let sleepDuration = try #require(await UsageStore.nextAdaptiveTimerSleepDuration(for: store)) + + let computedInterval = try #require(store.adaptiveRefreshComputedIntervalForTesting) + #expect(computedInterval == 30 * 60) + #expect(sleepDuration == .seconds(10)) + let scheduledAt = try #require(store.adaptiveRefreshScheduledAt) + #expect(scheduledAt.timeIntervalSince(Date()) > 29 * 60) + #expect(scheduledAt.timeIntervalSince(Date()) <= 30 * 60) + } + @Test func `fixed cadence advances from scheduled tick instead of refresh completion`() { let interval = Duration.milliseconds(100) diff --git a/Tests/CodexBarTests/BackgroundWorkPowerPolicyTests.swift b/Tests/CodexBarTests/BackgroundWorkPowerPolicyTests.swift new file mode 100644 index 0000000000..1ac3f5df4c --- /dev/null +++ b/Tests/CodexBarTests/BackgroundWorkPowerPolicyTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +@testable import CodexBar + +struct BackgroundWorkPowerPolicyTests { + @Test + func `disabled mode preserves requested automatic intervals`() { + #expect(BackgroundWorkPowerPolicy.automaticInterval(nil, lowPowerModeEnabled: false) == nil) + #expect(BackgroundWorkPowerPolicy.automaticInterval(300, lowPowerModeEnabled: false) == 300) + #expect(BackgroundWorkPowerPolicy.automaticInterval(3600, lowPowerModeEnabled: false) == 3600) + } + + @Test + func `enabled mode clamps automatic intervals to thirty minutes`() { + #expect(BackgroundWorkPowerPolicy.automaticInterval(nil, lowPowerModeEnabled: true) == nil) + #expect(BackgroundWorkPowerPolicy.automaticInterval(60, lowPowerModeEnabled: true) == 1800) + #expect(BackgroundWorkPowerPolicy.automaticInterval(1800, lowPowerModeEnabled: true) == 1800) + #expect(BackgroundWorkPowerPolicy.automaticInterval(3600, lowPowerModeEnabled: true) == 3600) + } + + @Test + func `usage refresh wiring applies the shared policy`() { + #expect(UsageStore.effectiveAutomaticRefreshInterval( + 60, + lowPowerModeEnabled: false) == 60) + #expect(UsageStore.effectiveAutomaticRefreshInterval( + 60, + lowPowerModeEnabled: true) == 1800.0) + #expect(UsageStore.effectiveAutomaticRefreshInterval( + nil, + lowPowerModeEnabled: true) == nil) + } +} diff --git a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift index 664e375524..98cc19efa0 100644 --- a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift +++ b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift @@ -1085,12 +1085,12 @@ extension CodexBackgroundRefreshCoalescingTests { } @Test - func `forced background enrichment runs dashboard under battery saver with user context`() async throws { + func `forced background enrichment runs dashboard under global low power mode with user context`() async throws { let settings = try self.makeSettingsStore( suite: "CodexBackgroundRefreshCoalescingTests-forced-dashboard-battery") settings.statusChecksEnabled = false settings.costUsageEnabled = false - settings.openAIWebBatterySaverEnabled = true + settings.backgroundWorkLowPowerModeEnabled = true let managedAccount = try Self.installManagedAccount( email: "managed@example.com", settings: settings) @@ -1130,6 +1130,56 @@ extension CodexBackgroundRefreshCoalescingTests { #expect(store.openAIDashboard?.signedInEmail == managedAccount.email) #expect(!store.hasForcedRefreshEnrichmentInFlight) } + + @Test + func `regular automatic enrichment suppresses dashboard when only global low power mode is enabled`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-automatic-dashboard-global-low-power") + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + settings.openAIWebBatterySaverEnabled = false + settings.backgroundWorkLowPowerModeEnabled = true + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + var dashboardLoadCount = 0 + store._test_providerRefreshOverride = { _ in } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + dashboardLoadCount += 1 + return OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()) + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_openAIDashboardLoaderOverride = nil + } + + // The first pass is startup, where the Web gate is always closed. Prime that state before + // exercising the regular automatic path so this test specifically proves the global saver + // participates in the runtime policy context. + await store.refresh(enrichmentMode: .automatic) + + await store.refresh(enrichmentMode: .automatic) + await store.openAIDashboardBackgroundRefreshTask?.value + + #expect(dashboardLoadCount == 0) + #expect(store.openAIDashboard == nil) + } } extension CodexBackgroundRefreshCoalescingTests { diff --git a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift index 78898609b1..e670b9876e 100644 --- a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift +++ b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift @@ -23,6 +23,12 @@ struct ProviderStorageFootprintTests { } } + @Test + func `global low power mode clamps automatic storage scans to thirty minutes`() { + #expect(UsageStore.automaticStorageRefreshInterval(lowPowerModeEnabled: false) == 5 * 60) + #expect(UsageStore.automaticStorageRefreshInterval(lowPowerModeEnabled: true) == 30 * 60) + } + @Test func `scanner sums nested regular files and skips symlink targets`() throws { let root = try Self.makeTemporaryDirectory() @@ -339,6 +345,7 @@ struct ProviderStorageFootprintTests { settings: settings, environmentBase: ["CODEX_HOME": codexHome.path]) settings.providerStorageFootprintsEnabled = true + settings.backgroundWorkLowPowerModeEnabled = true store.managedCodexAccountsForStorageOverride = [] await store.refreshStorageFootprintsForOverviewNow() diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index fe38f6ea71..59badf25fc 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -146,6 +146,33 @@ struct SettingsStoreCoverageTests { #expect(reloaded.agentSessionLabelStyle == .descriptiveAndProject) } + @Test + func `background low power mode defaults off persists and drives effective web saver`() throws { + let suite = "SettingsStoreCoverageTests-background-low-power" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let initial = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(initial.backgroundWorkLowPowerModeEnabled == false) + #expect(defaults.object(forKey: "backgroundWorkLowPowerModeEnabled") == nil) + #expect(initial.effectiveOpenAIWebBatterySaverEnabled == false) + + let revision = initial.backgroundWorkSettingsRevision + initial.backgroundWorkLowPowerModeEnabled = true + + #expect(initial.backgroundWorkSettingsRevision == revision + 1) + #expect(initial.effectiveOpenAIWebBatterySaverEnabled) + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.backgroundWorkLowPowerModeEnabled) + #expect(reloaded.effectiveOpenAIWebBatterySaverEnabled) + + reloaded.backgroundWorkLowPowerModeEnabled = false + reloaded.openAIWebBatterySaverEnabled = true + #expect(reloaded.effectiveOpenAIWebBatterySaverEnabled) + } + @Test func `multi account menu layout persists and bridges legacy show all token accounts`() throws { let suite = "SettingsStoreCoverageTests-multi-account-layout" diff --git a/Tests/CodexBarTests/UsageStoreResetBoundaryRefreshTests.swift b/Tests/CodexBarTests/UsageStoreResetBoundaryRefreshTests.swift index 1e11a59493..63ab31c4f7 100644 --- a/Tests/CodexBarTests/UsageStoreResetBoundaryRefreshTests.swift +++ b/Tests/CodexBarTests/UsageStoreResetBoundaryRefreshTests.swift @@ -18,6 +18,21 @@ struct UsageStoreResetBoundaryRefreshTests { #expect(refreshAt == resetsAt.addingTimeInterval(UsageStore.resetBoundaryRefreshGraceSeconds)) } + @Test + func `low power mode does not schedule reset boundary before thirty minutes`() { + let now = Date(timeIntervalSince1970: 1500) + let resetsAt = now.addingTimeInterval(5 * 60) + let snapshot = Self.snapshot(updatedAt: now, primaryResetsAt: resetsAt) + + let refreshAt = UsageStore.nextResetBoundaryRefreshDate( + snapshots: [.codex: snapshot], + normalRefreshInterval: 60 * 60, + minimumAutomaticRefreshInterval: BackgroundWorkPowerPolicy.lowPowerMinimumInterval, + now: now) + + #expect(refreshAt == now.addingTimeInterval(30 * 60)) + } + @Test func `schedules prompt refresh when reset boundary already passed`() { let now = Date(timeIntervalSince1970: 2000) diff --git a/Tests/CodexBarTests/UsageStoreTokenRefreshCadenceTests.swift b/Tests/CodexBarTests/UsageStoreTokenRefreshCadenceTests.swift index 2de7cd1374..f7dc21cdd8 100644 --- a/Tests/CodexBarTests/UsageStoreTokenRefreshCadenceTests.swift +++ b/Tests/CodexBarTests/UsageStoreTokenRefreshCadenceTests.swift @@ -27,4 +27,26 @@ struct UsageStoreTokenRefreshCadenceTests { func `manual refresh disables the automatic token cadence`() { #expect(UsageStore.tokenFetchTTL(for: .manual) == nil) } + + @Test(arguments: [ + (RefreshFrequency.oneMinute, 1800.0), + (.fiveMinutes, 1800.0), + (.fifteenMinutes, 1800.0), + (.thirtyMinutes, 1800.0), + ]) + func `global low power mode clamps automatic token scans to thirty minutes`( + frequency: RefreshFrequency, + expectedSeconds: TimeInterval) + { + #expect(UsageStore.tokenFetchTTL( + for: frequency, + lowPowerModeEnabled: true) == expectedSeconds) + } + + @Test + func `global low power mode preserves manual token refresh`() { + #expect(UsageStore.tokenFetchTTL( + for: .manual, + lowPowerModeEnabled: true) == nil) + } } diff --git a/docs/superpowers/plans/2026-07-30-global-low-power-mode.md b/docs/superpowers/plans/2026-07-30-global-low-power-mode.md new file mode 100644 index 0000000000..18a8392307 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-global-low-power-mode.md @@ -0,0 +1,261 @@ +# Global Low Power Mode Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a non-destructive global low-power mode that clamps CodexBar's automatic provider, local cost, storage, and OpenAI Web background work while preserving manual refreshes. + +**Architecture:** A pure `BackgroundWorkPowerPolicy` owns the 30-minute lower bound. `SettingsStore` persists one default-off toggle, and each automatic scheduling seam asks the shared policy for its effective interval. Manual entry points continue to bypass automatic cooldowns. + +**Tech Stack:** Swift 6.2+, SwiftUI, Observation, Swift Testing, UserDefaults, Swift Package Manager + +## Global Constraints + +- macOS minimum remains 14.0. +- Low-power minimum automatic interval is exactly 1800 seconds. +- `backgroundWorkLowPowerModeEnabled` defaults to `false`. +- Do not rewrite `refreshFrequency` or disable cost/storage settings. +- Manual refresh remains immediate. +- Do not change Agent Sessions cadence. +- Add no dependency and no telemetry. +- User-facing review material remains available in Chinese. + +--- + +### Task 1: Pure background power policy + +**Files:** +- Create: `Sources/CodexBar/BackgroundWorkPowerPolicy.swift` +- Create: `Tests/CodexBarTests/BackgroundWorkPowerPolicyTests.swift` + +**Interfaces:** +- Produces: `BackgroundWorkPowerPolicy.lowPowerMinimumInterval: TimeInterval` +- Produces: `BackgroundWorkPowerPolicy.automaticInterval(_:lowPowerModeEnabled:) -> TimeInterval?` + +- [ ] **Step 1: Write the failing policy tests** + +```swift +import Foundation +import Testing +@testable import CodexBar + +struct BackgroundWorkPowerPolicyTests { + @Test + func `disabled mode preserves requested automatic intervals`() { + #expect(BackgroundWorkPowerPolicy.automaticInterval(nil, lowPowerModeEnabled: false) == nil) + #expect(BackgroundWorkPowerPolicy.automaticInterval(300, lowPowerModeEnabled: false) == 300) + #expect(BackgroundWorkPowerPolicy.automaticInterval(3600, lowPowerModeEnabled: false) == 3600) + } + + @Test + func `enabled mode clamps automatic intervals to thirty minutes`() { + #expect(BackgroundWorkPowerPolicy.automaticInterval(nil, lowPowerModeEnabled: true) == nil) + #expect(BackgroundWorkPowerPolicy.automaticInterval(60, lowPowerModeEnabled: true) == 1800) + #expect(BackgroundWorkPowerPolicy.automaticInterval(1800, lowPowerModeEnabled: true) == 1800) + #expect(BackgroundWorkPowerPolicy.automaticInterval(3600, lowPowerModeEnabled: true) == 3600) + } +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run the repository's `swift test --filter BackgroundWorkPowerPolicyTests` command, with the documented local Command Line Tools compatibility flags when full Xcode is unavailable. + +Expected: FAIL because `BackgroundWorkPowerPolicy` does not exist. + +- [ ] **Step 3: Implement the minimal pure policy** + +```swift +import Foundation + +enum BackgroundWorkPowerPolicy { + static let lowPowerMinimumInterval: TimeInterval = 30 * 60 + + static func automaticInterval( + _ requested: TimeInterval?, + lowPowerModeEnabled: Bool) -> TimeInterval? + { + guard let requested else { return nil } + guard lowPowerModeEnabled else { return requested } + return max(requested, self.lowPowerMinimumInterval) + } +} +``` + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Expected: both policy tests pass. + +### Task 2: Persist and expose the global setting + +**Files:** +- Modify: `Sources/CodexBar/SettingsStoreState.swift` +- Modify: `Sources/CodexBar/SettingsStore.swift` +- Modify: `Sources/CodexBar/SettingsStore+Defaults.swift` +- Modify: `Sources/CodexBar/SettingsStore+MenuObservation.swift` +- Modify: `Tests/CodexBarTests/SettingsStoreCoverageTests.swift` + +**Interfaces:** +- Consumes: `SettingsStore.noteBackgroundWorkSettingsChanged()` +- Produces: `SettingsStore.backgroundWorkLowPowerModeEnabled: Bool` +- Produces: `SettingsStore.effectiveOpenAIWebBatterySaverEnabled: Bool` + +- [ ] **Step 1: Add failing settings persistence tests** + +Add a test that creates isolated defaults, verifies the new key defaults to false and is absent, toggles it on, verifies `backgroundWorkSettingsRevision` increments once, reloads `SettingsStore`, and verifies the value persists. Also assert: + +```swift +#expect(initial.effectiveOpenAIWebBatterySaverEnabled == false) +initial.backgroundWorkLowPowerModeEnabled = true +#expect(initial.effectiveOpenAIWebBatterySaverEnabled) +initial.backgroundWorkLowPowerModeEnabled = false +initial.openAIWebBatterySaverEnabled = true +#expect(initial.effectiveOpenAIWebBatterySaverEnabled) +``` + +- [ ] **Step 2: Run the focused settings test and verify RED** + +Expected: FAIL because the properties do not exist. + +- [ ] **Step 3: Add state loading, persistence, and observation** + +Add `backgroundWorkLowPowerModeEnabled` beside existing background-related settings. Load it with: + +```swift +let backgroundWorkLowPowerModeEnabled = + userDefaults.object(forKey: "backgroundWorkLowPowerModeEnabled") as? Bool ?? false +``` + +The setter stores the value, logs only `enabled=0|1`, and calls `noteBackgroundWorkSettingsChanged()`. The effective web saver is the OR of the global and provider-specific values. + +- [ ] **Step 4: Run the focused settings test and verify GREEN** + +Expected: default, persistence, revision, and effective-web assertions pass. + +### Task 3: Clamp provider and Adaptive refresh timers + +**Files:** +- Modify: `Sources/CodexBar/UsageStore.swift` +- Modify: `Sources/CodexBar/UsageStore+AdaptiveRefresh.swift` +- Modify: `Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift` + +**Interfaces:** +- Consumes: `BackgroundWorkPowerPolicy.automaticInterval(_:lowPowerModeEnabled:)` +- Consumes: `SettingsStore.backgroundWorkLowPowerModeEnabled` + +- [ ] **Step 1: Add failing timer tests** + +Add fixed and Adaptive cases proving a requested 300-second fixed interval and an Adaptive 300-second decision both resolve to 1800 seconds when the global setting is on, while 3600 seconds remains unchanged. Keep existing sleep overrides test-only and separate from the policy's computed delay. + +- [ ] **Step 2: Run the focused timer tests and verify RED** + +Expected: low-power cases observe the old short delay. + +- [ ] **Step 3: Apply the shared policy at all provider timer seams** + +In `startTimer`, clamp `frequency.seconds` before creating the fixed timer. In `nextAdaptiveTimerSleepDuration`, clamp the policy decision before assigning `adaptiveRefreshScheduledAt` and before sleeping. Apply the same clamped value in `advanceAdaptiveTimerIfEarlier` and `normalRefreshIntervalForHeuristics` so scheduling metadata does not disagree with the actual timer. + +- [ ] **Step 4: Run the focused timer tests and verify GREEN** + +Expected: existing timer behavior stays green and low-power cases resolve to 1800 seconds. + +### Task 4: Clamp local cost and storage scans + +**Files:** +- Modify: `Sources/CodexBar/UsageStore.swift` +- Modify: `Sources/CodexBar/UsageStore+ProviderStorage.swift` +- Modify: `Tests/CodexBarTests/UsageStoreTokenRefreshCadenceTests.swift` +- Modify: `Tests/CodexBarTests/ProviderStorageFootprintTests.swift` + +**Interfaces:** +- Consumes: `BackgroundWorkPowerPolicy.automaticInterval(_:lowPowerModeEnabled:)` +- Produces: `UsageStore.tokenFetchTTL(for:lowPowerModeEnabled:) -> TimeInterval?` +- Produces: `UsageStore.automaticStorageRefreshInterval(lowPowerModeEnabled:) -> TimeInterval` + +- [ ] **Step 1: Add failing cadence and manual-refresh tests** + +Extend token cadence table tests so five minutes becomes 1800 seconds only when low-power mode is enabled, manual remains nil, and one hour remains one hour. Add storage policy assertions for 300 versus 1800 seconds. Extend the existing manual storage refresh test by enabling global low-power mode and proving the explicit second refresh still sees a deleted directory immediately. + +- [ ] **Step 2: Run focused token and storage tests and verify RED** + +Expected: automatic token/storage intervals remain 300 seconds. + +- [ ] **Step 3: Route automatic token and storage cooldowns through the shared policy** + +Pass the global setting into `tokenFetchTTL`. Replace the storage constant comparison with `automaticStorageRefreshInterval(lowPowerModeEnabled:)`. Do not add a low-power guard to `refreshTokenUsageNow(force:)` or `refreshStorageFootprintsNow`. + +- [ ] **Step 4: Run focused token and storage tests and verify GREEN** + +Expected: automatic cooldowns clamp and explicit refreshes remain immediate. + +### Task 5: Apply global saver to OpenAI Web and expose UI + +**Files:** +- Modify: `Sources/CodexBar/UsageStore+OpenAIWeb.swift` +- Modify: `Sources/CodexBar/UsageStore+RefreshEnrichment.swift` +- Modify: `Sources/CodexBar/UsageStore+Logging.swift` +- Modify: `Sources/CodexBar/PreferencesGeneralPane.swift` +- Modify: `Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift` +- Modify: `Sources/CodexBar/Resources/en.lproj/Localizable.strings` +- Modify: `Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings` +- Modify: `Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift` + +**Interfaces:** +- Consumes: `SettingsStore.effectiveOpenAIWebBatterySaverEnabled` + +- [ ] **Step 1: Add a failing OpenAI Web gate test** + +Use existing refresh-policy test seams to set `backgroundWorkLowPowerModeEnabled = true`, +`openAIWebBatterySaverEnabled = false`, and verify a routine background Web refresh is suppressed while a forced +user refresh remains eligible. + +- [ ] **Step 2: Run the focused Web test and verify RED** + +Expected: routine Web refresh is still allowed. + +- [ ] **Step 3: Wire effective saver and add localized controls** + +Use `effectiveOpenAIWebBatterySaverEnabled` everywhere the runtime creates an `OpenAIWebRefreshPolicyContext`. +Add the General-pane toggle with the approved title/subtitle. Rename the provider-specific title to +`OpenAI web battery saver`. Keep the English literals as the built-in fallback and add audited Simplified Chinese +overrides; adding them to `en.lproj` would make every complete locale require an unaudited translation. + +- [ ] **Step 4: Run focused Web/settings/UI tests and verify GREEN** + +Expected: Web policy and persistence tests pass; UI source compiles. + +### Task 6: Verification, local package, and handoff + +**Files:** +- Modify only if needed: `docs/superpowers/specs/2026-07-30-global-low-power-mode-design.zh-CN.md` +- Create local artifact outside Git: `CodexBar-Low-Power-Local.app` + +**Interfaces:** +- Consumes: all prior tasks + +- [ ] **Step 1: Restore local-only compatibility exclusions before reviewing Git diff** + +Restore the five temporarily excluded XCTest files. Confirm the only tracked source compatibility change retained for the local Command Line Tools build is intentionally excluded from the feature commit or split into a separate commit. + +- [ ] **Step 2: Run format, lint, focused tests, and app build** + +Run `swiftformat --lint`, `swiftlint`, all changed-area tests, and `swift build`. When full Xcode is unavailable, record the exact local compatibility flags and distinguish environment limitations from source failures. + +- [ ] **Step 3: Inspect the final diff** + +Confirm no credentials, account identifiers, local paths, build cache changes, Widget removal, or unrelated provider changes are tracked. + +- [ ] **Step 4: Create focused commits with Codex attribution** + +Commit implementation and documentation using repository conventions. Add a Codex/ChatGPT co-author trailer, not a Claude trailer. + +- [ ] **Step 5: Build and ad-hoc sign a no-Widget local app** + +Assemble the app from the exact committed executable and resources, disable Sparkle auto-update, omit the Widget only for this local artifact, sign with `codesign --sign -`, verify the signature, and save a copy under the user's visible project path. + +- [ ] **Step 6: Back up and install** + +Quit CodexBar, copy the current `/Applications/CodexBar.app` to a timestamped backup, install the local app, launch it, verify the menu appears, enable Low Power Mode, and confirm existing settings remain readable. + +- [ ] **Step 7: Push the branch and prepare the upstream contribution** + +Push `codex/global-low-power-mode` to `Carl723000/CodexBar`. Prepare a PR referencing #2508 with the root cause, behavior table, tests, and explicit note that the no-Widget packaging workaround is local-only. diff --git a/docs/superpowers/specs/2026-07-30-global-low-power-mode-design.zh-CN.md b/docs/superpowers/specs/2026-07-30-global-low-power-mode-design.zh-CN.md new file mode 100644 index 0000000000..d162b30cd0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-global-low-power-mode-design.zh-CN.md @@ -0,0 +1,122 @@ +--- +summary: "为 CodexBar 增加非破坏性的全局低功耗后台刷新策略。" +read_when: + - 实现或审阅全局低功耗模式 + - 修改自动刷新、本地成本扫描、存储扫描或 OpenAI Web 后台刷新 +--- + +# CodexBar 全局低功耗模式设计 + +**状态:** 已批准(2026-07-30) + +**日期:** 2026-07-30 + +**关联问题:** [#2508](https://github.com/steipete/CodexBar/issues/2508) + +## 决策摘要 + +新增一个默认关闭的全局 **Low Power Mode(低功耗模式)**。开启后,CodexBar 的自动供应商刷新、 +本地 Token/成本扫描和供应商存储扫描都不得短于 30 分钟;OpenAI Web 的常规后台刷新同时按现有 +Battery Saver 规则处理。用户主动触发的刷新保持可用,不改写用户原有刷新频率,也不删除任何统计 +选项。 + +## 问题与根因 + +现有 Codex 提供商内的 **Battery Saver** 只限制 `chatgpt.com` Dashboard 抓取。它不覆盖以下独立 +后台通道: + +- `UsageStore.startTimer()` 驱动的供应商用量和状态刷新; +- `UsageStore.startTokenTimer()` 驱动的本地 Token/成本历史扫描; +- `UsageStore.scheduleStorageFootprintRefresh` 驱动的本地目录占用扫描。 + +在刷新频率为 5 分钟、开启本地 Codex 会话账本和存储统计时,上述三个通道仍可约每 5 分钟唤醒并 +遍历本地历史。因而用户即使开启了原有 Battery Saver,也可能继续看到较高能耗。 + +## 用户行为 + +在 Settings → General → Refreshing 中增加: + +- 标题:`Low Power Mode` +- 说明:`Runs automatic provider, local usage, and storage refreshes no more often than every 30 minutes. Manual refresh remains available.` +- 默认:关闭 + +开启时: + +| 通道 | 原行为 | 低功耗模式 | +| --- | --- | --- | +| 固定供应商刷新 | 使用所选的 1/2/5/15/30 分钟间隔 | 最低 30 分钟 | +| Adaptive 供应商刷新 | 按活动、电源和温度动态计算 | 保留动态决策,但结果最低 30 分钟 | +| 本地 Token/成本扫描 | 最低 5 分钟,跟随刷新策略 | 最低 30 分钟 | +| 供应商存储扫描 | 自动扫描冷却 5 分钟 | 自动扫描冷却 30 分钟 | +| OpenAI Web 常规后台刷新 | 由提供商专用 Battery Saver 控制 | 视为 Battery Saver 已开启 | +| 手动刷新 | 立即执行 | 不变 | + +关闭时立即恢复原有用户设置的有效行为。实现不得把已保存的 `refreshFrequency` 改成 30 分钟,也不得 +关闭本地成本或存储统计。 + +## 策略边界 + +增加一个纯策略 `BackgroundWorkPowerPolicy`,集中实现“低功耗模式下自动间隔不得短于 1800 秒”。 +供应商计时器、Adaptive 计时器、Token 计时器和存储冷却必须复用同一策略,避免四处复制数值和产生 +不一致。 + +`nil` 间隔代表手动模式或无自动工作,策略必须原样保留 `nil`。超过 30 分钟的用户间隔不得被缩短。 + +OpenAI Web 使用以下有效值: + +```text +effectiveWebBatterySaver = + openAIWebBatterySaverEnabled || backgroundWorkLowPowerModeEnabled +``` + +提供商专用开关仍独立保存;关闭全局低功耗模式后,它继续按用户原值生效。 + +## 设置和迁移 + +- 新键:`backgroundWorkLowPowerModeEnabled: Bool` +- 默认:`false` +- 不进行历史设置迁移; +- Setter 必须触发 `noteBackgroundWorkSettingsChanged()`,使现有计时器立即按新策略重建; +- 菜单观察令牌包含该设置,保证界面同步更新。 + +## 文案澄清 + +Codex 提供商中的旧标题由 `Battery Saver` 改为 `OpenAI web battery saver`,说明仍明确其只限制 +`chatgpt.com` 刷新。全局设置使用 `Low Power Mode`,避免两个开关被理解为同一作用域。 + +首个实现至少提供英文和简体中文本地化;其他语言缺失时沿用 CodexBar 的英文回退机制,不在本修复中 +批量生成未经审校的翻译。 + +## 非目标 + +- 不停止所有后台工作; +- 不改变手动刷新、菜单主动刷新或设置变更后的一次性同步; +- 不更改 Agent Sessions 的显式扫描周期; +- 不读取对话正文,也不新增遥测; +- 不增加依赖; +- 不实现动态“自动决定是否省电”的第二套策略; +- 不修改 Widget 数据结构或同步协议。 + +## 测试要求 + +- 纯策略在关闭时保持原间隔,在开启时把小于 1800 秒的自动间隔提升到 1800 秒; +- `nil` 保持 `nil`,大于 1800 秒的间隔保持不变; +- 设置默认关闭、可持久化,并触发后台工作 revision; +- 固定、Adaptive、Token 和存储自动间隔都使用同一低功耗下限; +- 手动 Token 刷新和手动存储刷新不受低功耗模式阻断; +- 全局低功耗模式会激活 OpenAI Web 的有效 Battery Saver,关闭后恢复提供商开关原值; +- 现有目标测试、格式检查和应用构建通过。 + +## 本地安装边界 + +Mac 可以安装自己编译的 CodexBar。开发包使用 ad-hoc 本地签名,自动更新关闭。由于当前机器没有完整 +Xcode,首个测试包可不包含 Widget;这不影响菜单栏 App、本地用量统计或本次低功耗策略。安装前备份 +现有 `/Applications/CodexBar.app`,失败时可直接恢复。 + +## 验收标准 + +1. 开启低功耗模式且原刷新频率为 5 分钟时,三个主要自动通道的有效冷却均为 30 分钟。 +2. 菜单和显式刷新仍能立即取得新数据。 +3. 关闭低功耗模式后无需重新设置,5 分钟配置恢复。 +4. 设置界面明确区分全局低功耗模式与 OpenAI Web 专用省电开关。 +5. 本地 ad-hoc 包能启动、显示菜单,并读取现有 CodexBar 设置。