Skip to content
1 change: 1 addition & 0 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ struct MenuDescriptor {
entries.append(.action("Update ready, restart now?", .installUpdate))
}
entries.append(contentsOf: [
.action("Refresh", .refresh),
.action("Settings...", .settings),
.action("About CodexBar", .about),
.action("Quit", .quit),
Expand Down
9 changes: 9 additions & 0 deletions Sources/CodexBar/PreferencesProvidersPane+Testing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ extension ProvidersPane {
func _test_menuCardModel(for provider: UsageProvider) -> UsageMenuCardView.Model {
self.menuCardModel(for: provider)
}

func _test_refreshAction(for provider: UsageProvider) -> String {
switch self.refreshAction(for: provider) {
case .fullStore:
"fullStore"
case .providerOnly:
"providerOnly"
}
}
}

@MainActor
Expand Down
37 changes: 32 additions & 5 deletions Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,7 @@ struct ProvidersPane: View {
isErrorExpanded: self.expandedBinding(for: provider),
onCopyError: { text in self.copyToPasteboard(text) },
onRefresh: {
Task { @MainActor in
await ProviderInteractionContext.$current.withValue(.userInitiated) {
await self.store.refreshProvider(provider, allowDisabled: true)
}
}
self.triggerRefresh(for: provider)
})
} else {
Text("Select a provider")
Expand Down Expand Up @@ -99,6 +95,37 @@ struct ProvidersPane: View {
self.selectedProvider = self.providers.first
}

enum RefreshAction {
case fullStore
case providerOnly
}

func refreshAction(for provider: UsageProvider) -> RefreshAction {
let metadata = self.store.metadata(for: provider)
let isEnabled = self.settings.isProviderEnabled(provider: provider, metadata: metadata)
if provider == .codex,
isEnabled,
self.settings.openAIWebAccessEnabled
{
return .fullStore
}
return .providerOnly
}

private func triggerRefresh(for provider: UsageProvider) {
let action = self.refreshAction(for: provider)
Task { @MainActor in
await ProviderInteractionContext.$current.withValue(.userInitiated) {
switch action {
case .fullStore:
await self.store.refresh(forceTokenUsage: true)
case .providerOnly:
await self.store.refreshProvider(provider, allowDisabled: true)
}
}
}
}

func binding(for provider: UsageProvider) -> Binding<Bool> {
let meta = self.store.metadata(for: provider)
return Binding(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ struct CodexProviderImplementation: ProviderImplementation {
for: .codex)
}
})
let batterySaverBinding = context.boolBinding(\.openAIWebBatterySaverEnabled)

return [
ProviderSettingsToggleDescriptor(
Expand All @@ -85,14 +86,32 @@ struct CodexProviderImplementation: ProviderImplementation {
ProviderSettingsToggleDescriptor(
id: "codex-openai-web-extras",
title: "OpenAI web extras",
subtitle: "Show usage breakdown, credits history, and code review via chatgpt.com.",
subtitle: [
"Optional.",
"Turn this on to show code review, usage breakdown, and credits history via chatgpt.com.",
].joined(separator: " "),
binding: extrasBinding,
statusText: nil,
actions: [],
isVisible: nil,
onChange: nil,
onAppDidBecomeActive: nil,
onAppearWhenEnabled: nil),
ProviderSettingsToggleDescriptor(
id: "codex-openai-web-battery-saver",
title: "Battery Saver",
subtitle: [
"Recommended.",
"Limits background chatgpt.com refreshes to reduce battery and network usage.",
"Dashboard extras may stay stale until you refresh them manually.",
].joined(separator: " "),
binding: batterySaverBinding,
statusText: nil,
actions: [],
isVisible: { context.settings.openAIWebAccessEnabled },
onChange: nil,
onAppDidBecomeActive: nil,
onAppearWhenEnabled: nil),
]
}

Expand Down
11 changes: 11 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,17 @@ extension SettingsStore {
}
}

var openAIWebBatterySaverEnabled: Bool {
get { self.defaultsState.openAIWebBatterySaverEnabled }
set {
self.defaultsState.openAIWebBatterySaverEnabled = newValue
self.userDefaults.set(newValue, forKey: "openAIWebBatterySaverEnabled")
CodexBarLog.logger(LogCategories.settings).info(
"OpenAI web battery saver updated",
metadata: ["enabled": newValue ? "1" : "0"])
}
}

var jetbrainsIDEBasePath: String {
get { self.defaultsState.jetbrainsIDEBasePath }
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 @@ -27,6 +27,7 @@ extension SettingsStore {
_ = self.claudeWebExtrasEnabled
_ = self.showOptionalCreditsAndExtraUsage
_ = self.openAIWebAccessEnabled
_ = self.openAIWebBatterySaverEnabled
_ = self.codexUsageDataSource
_ = self.claudeUsageDataSource
_ = self.kiloUsageDataSource
Expand Down
21 changes: 18 additions & 3 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ final class SettingsStore {
copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore(),
tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore())
{
let hasStoredOpenAIWebAccessPreference = userDefaults.object(forKey: "openAIWebAccessEnabled") != nil
let legacyStores = CodexBarConfigMigrator.LegacyStores(
zaiTokenStore: zaiTokenStore,
syntheticTokenStore: syntheticTokenStore,
Expand Down Expand Up @@ -152,13 +153,23 @@ final class SettingsStore {
self.ensureAlibabaProviderAutoEnabledIfNeeded()
self.applyTokenCostDefaultIfNeeded()
if self.claudeUsageDataSource != .cli { self.claudeWebExtrasEnabled = false }
self.openAIWebAccessEnabled = self.codexCookieSource.isEnabled
if hasStoredOpenAIWebAccessPreference {
self.openAIWebAccessEnabled = self.defaultsState.openAIWebAccessEnabled
} else {
self.openAIWebAccessEnabled = Self.inferredInitialOpenAIWebAccessEnabled(config: config)
}
Self.sharedDefaults?.set(self.debugDisableKeychainAccess, forKey: "debugDisableKeychainAccess")
KeychainAccessGate.isDisabled = self.debugDisableKeychainAccess
}
}

extension SettingsStore {
private static func inferredInitialOpenAIWebAccessEnabled(config: CodexBarConfig) -> Bool {
guard let codex = config.providerConfig(for: .codex) else { return false }
if let cookieSource = codex.cookieSource { return cookieSource.isEnabled }
return codex.sanitizedCookieHeader != nil
}

private static func loadDefaultsState(userDefaults: UserDefaults) -> SettingsDefaultsState {
let refreshDefault = userDefaults.string(forKey: "refreshFrequency")
.flatMap(RefreshFrequency.init(rawValue:))
Expand Down Expand Up @@ -219,8 +230,11 @@ extension SettingsStore {
let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true
if creditsExtrasDefault == nil { userDefaults.set(true, forKey: "showOptionalCreditsAndExtraUsage") }
let openAIWebAccessDefault = userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool
let openAIWebAccessEnabled = openAIWebAccessDefault ?? true
if openAIWebAccessDefault == nil { userDefaults.set(true, forKey: "openAIWebAccessEnabled") }
let openAIWebAccessEnabled = openAIWebAccessDefault ?? false
if openAIWebAccessDefault == nil { userDefaults.set(false, forKey: "openAIWebAccessEnabled") }
let openAIWebBatterySaverDefault = userDefaults.object(forKey: "openAIWebBatterySaverEnabled") as? Bool
let openAIWebBatterySaverEnabled = openAIWebBatterySaverDefault ?? true
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick question: for someone who already had OpenAI Web Extras enabled before this setting existed, do we want them to land in Battery Saver automatically on upgrade, or is it worth checking whether their current refresh behavior should be preserved until they choose otherwise?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intentionally kept existing users on the safe path here. Given how severe the battery regression was, I think defaulting Battery Saver to on for upgrades is the right migration, even for users who previously had OpenAI Web Extras enabled.

if openAIWebBatterySaverDefault == nil { userDefaults.set(true, forKey: "openAIWebBatterySaverEnabled") }
let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? ""
let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true
let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true
Expand Down Expand Up @@ -258,6 +272,7 @@ extension SettingsStore {
claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw,
showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage,
openAIWebAccessEnabled: openAIWebAccessEnabled,
openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled,
jetbrainsIDEBasePath: jetbrainsIDEBasePath,
mergeIcons: mergeIcons,
switcherShowsIcons: switcherShowsIcons,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ struct SettingsDefaultsState {
var claudeWebExtrasEnabledRaw: Bool
var showOptionalCreditsAndExtraUsage: Bool
var openAIWebAccessEnabled: Bool
var openAIWebBatterySaverEnabled: Bool
var jetbrainsIDEBasePath: String
var mergeIcons: Bool
var switcherShowsIcons: Bool
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ extension StatusItemController {
{
let dashboard = self.store.openAIDashboard
let openAIWebEligible = currentProvider == .codex &&
self.settings.openAIWebAccessEnabled &&
self.store.openAIDashboardRequiresLogin == false &&
dashboard != nil
let hasCreditsHistory = openAIWebEligible && !(dashboard?.dailyBreakdown ?? []).isEmpty
Expand Down
24 changes: 24 additions & 0 deletions Sources/CodexBar/UsageStore+BackgroundRefresh.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import CodexBarCore
import Foundation

@MainActor
extension UsageStore {
func clearDisabledProviderState(enabledProviders: Set<UsageProvider>) {
for provider in UsageProvider.allCases where !enabledProviders.contains(provider) {
self.refreshingProviders.remove(provider)
self.snapshots.removeValue(forKey: provider)
self.errors[provider] = nil
self.lastSourceLabels.removeValue(forKey: provider)
self.lastFetchAttempts.removeValue(forKey: provider)
self.accountSnapshots.removeValue(forKey: provider)
self.tokenSnapshots.removeValue(forKey: provider)
self.tokenErrors[provider] = nil
self.failureGates[provider]?.reset()
self.tokenFailureGates[provider]?.reset()
self.statuses.removeValue(forKey: provider)
self.lastKnownSessionRemaining.removeValue(forKey: provider)
self.lastKnownSessionWindowSource.removeValue(forKey: provider)
self.lastTokenFetchAt.removeValue(forKey: provider)
}
}
}
1 change: 1 addition & 0 deletions Sources/CodexBar/UsageStore+Logging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ extension UsageStore {
"ampCookieSource": self.settings.ampCookieSource.rawValue,
"ollamaCookieSource": self.settings.ollamaCookieSource.rawValue,
"openAIWebAccess": self.settings.openAIWebAccessEnabled ? "1" : "0",
"openAIWebBatterySaver": self.settings.openAIWebBatterySaverEnabled ? "1" : "0",
"claudeWebExtras": self.settings.claudeWebExtrasEnabled ? "1" : "0",
"kiloExtras": self.settings.kiloExtrasEnabled ? "1" : "0",
]
Expand Down
73 changes: 73 additions & 0 deletions Sources/CodexBar/UsageStore+OpenAIWeb.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,81 @@
import Foundation

struct OpenAIWebRefreshGateContext {
let force: Bool
let accountDidChange: Bool
let lastError: String?
let lastSnapshotAt: Date?
let lastAttemptAt: Date?
let now: Date
let refreshInterval: TimeInterval
}

struct OpenAIWebRefreshPolicyContext {
let accessEnabled: Bool
let batterySaverEnabled: Bool
let force: Bool
}

// MARK: - OpenAI web error messaging

extension UsageStore {
nonisolated static func shouldRunOpenAIWebRefresh(_ context: OpenAIWebRefreshPolicyContext) -> Bool {
guard context.accessEnabled else { return false }
return context.force || !context.batterySaverEnabled
}

nonisolated static func forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: Bool) -> Bool {
!batterySaverEnabled
}

nonisolated static func shouldSkipOpenAIWebRefresh(_ context: OpenAIWebRefreshGateContext) -> Bool {
if context.force || context.accountDidChange { return false }
if let lastAttemptAt = context.lastAttemptAt,
context.now.timeIntervalSince(lastAttemptAt) < context.refreshInterval
{
return true
}
if context.lastError == nil,
let lastSnapshotAt = context.lastSnapshotAt,
context.now.timeIntervalSince(lastSnapshotAt) < context.refreshInterval
{
return true
}
return false
}

func syncOpenAIWebState() {
guard self.isEnabled(.codex),
self.settings.openAIWebAccessEnabled,
self.settings.codexCookieSource.isEnabled
else {
self.resetOpenAIWebState()
return
}

let targetEmail = self.codexAccountEmailForOpenAIDashboard()
self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail)
}

func dashboardEmailMismatch(expected: String?, actual: String?) -> Bool {
guard let expected, !expected.isEmpty else { return false }
guard let raw = actual?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return false }
return raw.lowercased() != expected.lowercased()
}

func codexAccountEmailForOpenAIDashboard() -> String? {
let direct = self.snapshots[.codex]?.accountEmail(for: .codex)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let direct, !direct.isEmpty { return direct }
let fallback = self.codexFetcher.loadAccountInfo().email?.trimmingCharacters(in: .whitespacesAndNewlines)
if let fallback, !fallback.isEmpty { return fallback }
let cached = self.openAIDashboard?.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
if let cached, !cached.isEmpty { return cached }
let imported = self.lastOpenAIDashboardCookieImportEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
if let imported, !imported.isEmpty { return imported }
return nil
}

func openAIDashboardFriendlyError(
body: String,
targetEmail: String?,
Expand Down
Loading