diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 8f9eb75f6a..5bd2d48184 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -178,13 +178,18 @@ 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.")) - } + SettingsMenuPicker( + selection: self.$settings.backgroundWorkLowPowerModePreference, + options: GeneralSettingsMenuOptions.lowPowerModePreferences, + label: { + SettingsRowLabel( + L("Low Power Mode"), + subtitle: L( + "When on, runs automatic provider, local usage, and storage refreshes no more " + + "often than every 30 minutes. Manual refresh remains available. Automatic " + + "follows the system Low Power Mode setting.")) + }, + optionLabel: { option in Text(option.label) }) Toggle(isOn: self.$settings.statusChecksEnabled) { SettingsRowLabel( diff --git a/Sources/CodexBar/PreferencesMenuPicker.swift b/Sources/CodexBar/PreferencesMenuPicker.swift index 5a81f7f398..523a7a1b77 100644 --- a/Sources/CodexBar/PreferencesMenuPicker.swift +++ b/Sources/CodexBar/PreferencesMenuPicker.swift @@ -50,6 +50,7 @@ struct SettingsMenuPicker: View enum GeneralSettingsMenuOptions { static let languages = AppLanguage.allCases.map(\.rawValue) static let refreshFrequencies = RefreshFrequency.allCases + static let lowPowerModePreferences = LowPowerModePreference.allCases static func terminalApps(selected: TerminalApp) -> [TerminalApp] { TerminalApp.pickerOptions(selected: selected) diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 1c7705209c..1681697fee 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -303,7 +303,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 分钟运行一次;仍可随时手动刷新。"; +"When on, runs automatic provider, local usage, and storage refreshes no more often than every 30 minutes. Manual refresh remains available. Automatic follows the system Low Power Mode setting." = "开启后,自动供应商刷新、本地用量扫描和存储扫描最多每 30 分钟运行一次;仍可随时手动刷新。选择“自动”时将跟随系统的低电量模式设置。"; "Refresh" = "刷新"; "Refreshing" = "正在刷新"; "Refresh cadence" = "刷新频率"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index ce25e10cfd..c1e59085d4 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -764,18 +764,28 @@ extension SettingsStore { } } - var backgroundWorkLowPowerModeEnabled: Bool { - get { self.defaultsState.backgroundWorkLowPowerModeEnabled } + var backgroundWorkLowPowerModePreference: LowPowerModePreference { + get { self.defaultsState.backgroundWorkLowPowerModePreference } set { - self.defaultsState.backgroundWorkLowPowerModeEnabled = newValue - self.userDefaults.set(newValue, forKey: "backgroundWorkLowPowerModeEnabled") + self.defaultsState.backgroundWorkLowPowerModePreference = newValue + self.userDefaults.set(newValue.rawValue, forKey: "backgroundWorkLowPowerModePreference") CodexBarLog.logger(LogCategories.settings).info( - "Background work low power mode updated", - metadata: ["enabled": newValue ? "1" : "0"]) + "Background work low power mode preference updated", + metadata: ["preference": newValue.rawValue]) self.noteBackgroundWorkSettingsChanged() } } + /// Resolves `backgroundWorkLowPowerModePreference` against the live system Low Power Mode state + /// when the preference is `.automatic`. + var backgroundWorkLowPowerModeEnabled: Bool { + switch self.backgroundWorkLowPowerModePreference { + case .off: false + case .on: true + case .automatic: ProcessInfo.processInfo.isLowPowerModeEnabled + } + } + var effectiveOpenAIWebBatterySaverEnabled: Bool { self.openAIWebBatterySaverEnabled || self.backgroundWorkLowPowerModeEnabled } diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 6e34e8f6e1..3b01482606 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -64,7 +64,7 @@ extension SettingsStore { _ = self.codexSparkUsageVisible _ = self.openAIWebAccessEnabled _ = self.openAIWebBatterySaverEnabled - _ = self.backgroundWorkLowPowerModeEnabled + _ = self.backgroundWorkLowPowerModePreference _ = self.providerStorageFootprintsEnabled _ = self.agentSessionsEnabled _ = self.agentSessionLabelStyle diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index f8d80dd8db..22cea5d532 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -118,6 +118,24 @@ enum KiroMenuBarDisplayMode: String, CaseIterable, Identifiable { } } +enum LowPowerModePreference: String, CaseIterable, Identifiable { + case off + case on + case automatic + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .off: L("Off") + case .on: L("On") + case .automatic: L("Automatic") + } + } +} + enum MultiAccountMenuLayout: String, CaseIterable, Identifiable { case segmented case stacked @@ -247,6 +265,7 @@ final class SettingsStore { #endif @ObservationIgnored var mergedMenuLastSelectedWasOverviewStorage = false @ObservationIgnored var selectedMenuProviderRawStorage: String? + @ObservationIgnored private nonisolated(unsafe) var lowPowerModeObserver: NSObjectProtocol? var defaultsState: SettingsDefaultsState var configRevision: Int = 0 var providerDetailSettingsRevision: Int = 0 @@ -400,10 +419,31 @@ final class SettingsStore { } KeychainAccessGate.isDisabled = self.debugDisableKeychainAccess self.startConfigFileWatcher() + self.observeSystemPowerStateChanges() } deinit { self.configFileWatcher?.stop() + if let lowPowerModeObserver { + NotificationCenter.default.removeObserver(lowPowerModeObserver) + } + } + + /// Automatic Low Power Mode reads `ProcessInfo.isLowPowerModeEnabled` live, but background + /// timers only restart when `backgroundWorkSettingsRevision` changes. Without this, toggling + /// the system's Low Power Mode mid-session would leave a running fixed-frequency timer stuck + /// at its previously computed interval until an unrelated settings change restarted it. + private func observeSystemPowerStateChanges() { + self.lowPowerModeObserver = NotificationCenter.default.addObserver( + forName: .NSProcessInfoPowerStateDidChange, + object: nil, + queue: .main) + { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, self.backgroundWorkLowPowerModePreference == .automatic else { return } + self.noteBackgroundWorkSettingsChanged() + } + } } } @@ -555,8 +595,7 @@ extension SettingsStore { if Self.isRunningTests, openAIWebBatterySaverDefault == nil { userDefaults.set(false, forKey: "openAIWebBatterySaverEnabled") } - let backgroundWorkLowPowerModeEnabled = - userDefaults.object(forKey: "backgroundWorkLowPowerModeEnabled") as? Bool ?? false + let backgroundWorkLowPowerModePreference = Self.loadLowPowerModePreference(userDefaults: userDefaults) let providerStorageFootprintsDefault = userDefaults.object(forKey: "providerStorageFootprintsEnabled") as? Bool let providerStorageFootprintsEnabled = providerStorageFootprintsDefault ?? false if Self.isRunningTests, providerStorageFootprintsDefault == nil { @@ -652,7 +691,7 @@ extension SettingsStore { codexExternalOAuthSourcesAllowed: codexExternalOAuthSourcesAllowed, openAIWebAccessEnabled: openAIWebAccessEnabled, openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled, - backgroundWorkLowPowerModeEnabled: backgroundWorkLowPowerModeEnabled, + backgroundWorkLowPowerModePreference: backgroundWorkLowPowerModePreference, providerStorageFootprintsEnabled: providerStorageFootprintsEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, mergeIcons: mergeIcons, @@ -698,6 +737,20 @@ extension SettingsStore { return frequency } + private static func loadLowPowerModePreference(userDefaults: UserDefaults) -> LowPowerModePreference { + if let stored = userDefaults.string(forKey: "backgroundWorkLowPowerModePreference"), + let preference = LowPowerModePreference(rawValue: stored) + { + return preference + } + + // Migrate the legacy on/off toggle, preserving prior behavior exactly (default off). + let legacyEnabled = userDefaults.object(forKey: "backgroundWorkLowPowerModeEnabled") as? Bool ?? false + let preference: LowPowerModePreference = legacyEnabled ? .on : .off + userDefaults.set(preference.rawValue, forKey: "backgroundWorkLowPowerModePreference") + return preference + } + private static func loadAdaptiveActivityScanConsent( userDefaults: UserDefaults) -> AdaptiveActivityScanConsent { diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 694ce3844b..d34a34926a 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -65,7 +65,7 @@ struct SettingsDefaultsState { var codexExternalOAuthSourcesAllowed: Bool var openAIWebAccessEnabled: Bool var openAIWebBatterySaverEnabled: Bool - var backgroundWorkLowPowerModeEnabled: Bool + var backgroundWorkLowPowerModePreference: LowPowerModePreference var providerStorageFootprintsEnabled: Bool var jetbrainsIDEBasePath: String var mergeIcons: Bool diff --git a/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift b/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift index be1bc213be..b4bd69902e 100644 --- a/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift +++ b/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift @@ -35,16 +35,16 @@ struct AdaptiveRefreshHeuristicsTests { @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 + fixedStore.settings.backgroundWorkLowPowerModePreference = .on #expect(fixedStore.normalRefreshIntervalForHeuristics() == 1800.0) let adaptiveStore = Self.makeStore(suite: "heuristics-low-power-adaptive", frequency: .adaptive) - adaptiveStore.settings.backgroundWorkLowPowerModeEnabled = true + adaptiveStore.settings.backgroundWorkLowPowerModePreference = .on adaptiveStore.noteMenuOpened() #expect(adaptiveStore.normalRefreshIntervalForHeuristics() == 1800.0) let manualStore = Self.makeStore(suite: "heuristics-low-power-manual", frequency: .manual) - manualStore.settings.backgroundWorkLowPowerModeEnabled = true + manualStore.settings.backgroundWorkLowPowerModePreference = .on #expect(manualStore.normalRefreshIntervalForHeuristics() == nil) } diff --git a/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift b/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift index 65265465f0..530f73bc6b 100644 --- a/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift +++ b/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift @@ -215,7 +215,7 @@ struct AdaptiveRefreshTimerTests { let settings = Self.makeSettingsStore( suite: "AdaptiveRefreshTimerTests-fixed-global-low-power", frequency: .fiveMinutes) - settings.backgroundWorkLowPowerModeEnabled = true + settings.backgroundWorkLowPowerModePreference = .on let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) store.restartTimerWithSleepOverrideForTesting(.seconds(10)) @@ -230,7 +230,7 @@ struct AdaptiveRefreshTimerTests { let settings = Self.makeSettingsStore( suite: "AdaptiveRefreshTimerTests-adaptive-global-low-power", frequency: .adaptive) - settings.backgroundWorkLowPowerModeEnabled = true + settings.backgroundWorkLowPowerModePreference = .on let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) let now = Date() store.noteMenuOpened(at: now.addingTimeInterval(-10 * 60)) diff --git a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift index 98cc19efa0..4da7f9b5c7 100644 --- a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift +++ b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift @@ -1090,7 +1090,7 @@ extension CodexBackgroundRefreshCoalescingTests { suite: "CodexBackgroundRefreshCoalescingTests-forced-dashboard-battery") settings.statusChecksEnabled = false settings.costUsageEnabled = false - settings.backgroundWorkLowPowerModeEnabled = true + settings.backgroundWorkLowPowerModePreference = .on let managedAccount = try Self.installManagedAccount( email: "managed@example.com", settings: settings) @@ -1138,7 +1138,7 @@ extension CodexBackgroundRefreshCoalescingTests { settings.statusChecksEnabled = false settings.costUsageEnabled = false settings.openAIWebBatterySaverEnabled = false - settings.backgroundWorkLowPowerModeEnabled = true + settings.backgroundWorkLowPowerModePreference = .on let managedAccount = try Self.installManagedAccount( email: "managed@example.com", settings: settings) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 7b87895747..f75045214f 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2281,7 +2281,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SettingsStore.swift", - line: 1073, + line: 1126, anchor: "if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) {", expectedProviderIDs: ["factory", "minimax", "zai"], expectedReferenceCount: 8, diff --git a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift index e670b9876e..9f7c119e19 100644 --- a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift +++ b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift @@ -345,7 +345,7 @@ struct ProviderStorageFootprintTests { settings: settings, environmentBase: ["CODEX_HOME": codexHome.path]) settings.providerStorageFootprintsEnabled = true - settings.backgroundWorkLowPowerModeEnabled = true + settings.backgroundWorkLowPowerModePreference = .on store.managedCodexAccountsForStorageOverride = [] await store.refreshStorageFootprintsForOverviewNow() diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 9a21605189..dd6abb881e 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -154,12 +154,13 @@ struct SettingsStoreCoverageTests { let configStore = testConfigStore(suiteName: suite) let initial = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(initial.backgroundWorkLowPowerModePreference == .off) #expect(initial.backgroundWorkLowPowerModeEnabled == false) - #expect(defaults.object(forKey: "backgroundWorkLowPowerModeEnabled") == nil) + #expect(defaults.string(forKey: "backgroundWorkLowPowerModePreference") == "off") #expect(initial.effectiveOpenAIWebBatterySaverEnabled == false) let revision = initial.backgroundWorkSettingsRevision - initial.backgroundWorkLowPowerModeEnabled = true + initial.backgroundWorkLowPowerModePreference = .on #expect(initial.backgroundWorkSettingsRevision == revision + 1) #expect(initial.effectiveOpenAIWebBatterySaverEnabled) @@ -168,11 +169,24 @@ struct SettingsStoreCoverageTests { #expect(reloaded.backgroundWorkLowPowerModeEnabled) #expect(reloaded.effectiveOpenAIWebBatterySaverEnabled) - reloaded.backgroundWorkLowPowerModeEnabled = false + reloaded.backgroundWorkLowPowerModePreference = .off reloaded.openAIWebBatterySaverEnabled = true #expect(reloaded.effectiveOpenAIWebBatterySaverEnabled) } + @Test + func `background low power mode migrates legacy enabled flag to on preference`() throws { + let suite = "SettingsStoreCoverageTests-background-low-power-migration" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "backgroundWorkLowPowerModeEnabled") + let configStore = testConfigStore(suiteName: suite) + + let migrated = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(migrated.backgroundWorkLowPowerModePreference == .on) + #expect(defaults.string(forKey: "backgroundWorkLowPowerModePreference") == "on") + } + @Test func `multi account menu layout persists and bridges legacy show all token accounts`() throws { let suite = "SettingsStoreCoverageTests-multi-account-layout"