Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
14 changes: 14 additions & 0 deletions Sources/CodexBar/BackgroundWorkPowerPolicy.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions Sources/CodexBar/PreferencesGeneralPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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, " = "托管账户存储不可读。实时账户访问仍可用,";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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" = "刷新频率";
Expand Down
16 changes: 16 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStore+MenuObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ extension SettingsStore {
_ = self.codexSparkUsageVisible
_ = self.openAIWebAccessEnabled
_ = self.openAIWebBatterySaverEnabled
_ = self.backgroundWorkLowPowerModeEnabled
_ = self.providerStorageFootprintsEnabled
_ = self.agentSessionsEnabled
_ = self.agentSessionLabelStyle
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -591,6 +593,7 @@ extension SettingsStore {
codexSparkUsageVisible: codexSparkUsageVisible,
openAIWebAccessEnabled: openAIWebAccessEnabled,
openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled,
backgroundWorkLowPowerModeEnabled: backgroundWorkLowPowerModeEnabled,
providerStorageFootprintsEnabled: providerStorageFootprintsEnabled,
jetbrainsIDEBasePath: jetbrainsIDEBasePath,
mergeIcons: mergeIcons,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 27 additions & 4 deletions Sources/CodexBar/UsageStore+AdaptiveRefresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -151,5 +171,8 @@ extension UsageStore {
default:
self.settings.refreshFrequency.seconds
}
return Self.effectiveAutomaticRefreshInterval(
requested,
lowPowerModeEnabled: self.settings.backgroundWorkLowPowerModeEnabled)
}
}
2 changes: 2 additions & 0 deletions Sources/CodexBar/UsageStore+Logging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
4 changes: 2 additions & 2 deletions Sources/CodexBar/UsageStore+OpenAIWeb.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 7 additions & 2 deletions Sources/CodexBar/UsageStore+ProviderStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/UsageStore+RefreshEnrichment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 17 additions & 3 deletions Sources/CodexBar/UsageStore+ResetBoundaryRefresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -67,13 +71,15 @@ extension UsageStore {
nonisolated static func nextResetBoundaryRefreshDate(
snapshots: [UsageProvider: UsageSnapshot],
normalRefreshInterval: TimeInterval?,
minimumAutomaticRefreshInterval: TimeInterval? = nil,
attemptedBoundaryRefreshes: Set<Date> = [],
now: Date)
-> Date?
{
self.nextResetBoundaryRefreshCandidate(
snapshots: snapshots,
normalRefreshInterval: normalRefreshInterval,
minimumAutomaticRefreshInterval: minimumAutomaticRefreshInterval,
attemptedBoundaryRefreshes: attemptedBoundaryRefreshes,
now: now)?
.refreshAt
Expand All @@ -86,18 +92,21 @@ extension UsageStore {
private nonisolated static func nextResetBoundaryRefreshCandidate(
snapshots: [UsageProvider: UsageSnapshot],
normalRefreshInterval: TimeInterval?,
minimumAutomaticRefreshInterval: TimeInterval?,
attemptedBoundaryRefreshes: Set<Date> = [],
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 }
Expand All @@ -107,6 +116,7 @@ extension UsageStore {
snapshot: UsageSnapshot,
now: Date,
normalRefreshDate: Date,
earliestAutomaticRefreshDate: Date?,
attemptedBoundaryRefreshes: Set<Date>)
-> [ResetBoundaryRefreshCandidate]
{
Expand All @@ -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)
}
}
Expand Down
26 changes: 22 additions & 4 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand All @@ -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
Expand Down
Loading