From 5f909939f2fb73d392f8ba318c59e4ec7614381d Mon Sep 17 00:00:00 2001 From: cbrane Date: Fri, 13 Mar 2026 20:47:55 -0400 Subject: [PATCH 1/4] Reduce battery drain from OpenAI web extras --- .../Codex/CodexProviderImplementation.swift | 6 +- Sources/CodexBar/SettingsStore.swift | 17 +++- .../CodexBar/StatusItemController+Menu.swift | 1 + .../UsageStore+BackgroundRefresh.swift | 24 +++++ Sources/CodexBar/UsageStore+OpenAIWeb.swift | 58 +++++++++++ Sources/CodexBar/UsageStore+Testing.swift | 23 +++++ Sources/CodexBar/UsageStore.swift | 98 ++++++++----------- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 4 + .../OpenAIDashboardWebViewCache.swift | 40 ++++++-- .../OpenAIDashboardWebViewCacheTests.swift | 54 ++++++++-- .../OpenAIWebRefreshGateTests.swift | 69 +++++++++++++ .../ProviderSettingsDescriptorTests.swift | 43 ++++++++ Tests/CodexBarTests/SettingsStoreTests.swift | 51 +++++++++- Tests/CodexBarTests/StatusMenuTests.swift | 50 ++++++++++ .../UsageStoreCoverageTests.swift | 33 +++++++ docs/codex.md | 5 +- docs/providers.md | 2 +- ...eb-extras-default-off-codexbar-20260307.md | 66 +++++++++++++ 18 files changed, 564 insertions(+), 80 deletions(-) create mode 100644 Sources/CodexBar/UsageStore+BackgroundRefresh.swift create mode 100644 Sources/CodexBar/UsageStore+Testing.swift create mode 100644 Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift create mode 100644 docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 35baa270d..dbdada705 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -74,7 +74,11 @@ 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.", + "This may increase battery or network usage.", + ].joined(separator: " "), binding: extrasBinding, statusText: nil, actions: [], diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index a45a78cf6..cb7e5aa01 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -115,6 +115,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, @@ -149,13 +150,23 @@ final class SettingsStore { self.runInitialProviderDetectionIfNeeded() 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 refreshRaw = userDefaults.string(forKey: "refreshFrequency") ?? RefreshFrequency.fiveMinutes.rawValue let refreshFrequency = RefreshFrequency(rawValue: refreshRaw) ?? .fiveMinutes @@ -211,8 +222,8 @@ 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 jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? "" let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 2e7df0192..9078953ac 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -315,6 +315,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 diff --git a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift new file mode 100644 index 000000000..cfe602df3 --- /dev/null +++ b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift @@ -0,0 +1,24 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + func clearDisabledProviderState(enabledProviders: Set) { + 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) + } + } +} diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index af164cc62..60d686dbe 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -1,8 +1,66 @@ 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 +} + // MARK: - OpenAI web error messaging extension UsageStore { + 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?, diff --git a/Sources/CodexBar/UsageStore+Testing.swift b/Sources/CodexBar/UsageStore+Testing.swift new file mode 100644 index 000000000..495d664ad --- /dev/null +++ b/Sources/CodexBar/UsageStore+Testing.swift @@ -0,0 +1,23 @@ +import CodexBarCore +import Foundation + +#if DEBUG +@MainActor +extension UsageStore { + func _setSnapshotForTesting(_ snapshot: UsageSnapshot?, provider: UsageProvider) { + self.snapshots[provider] = snapshot?.scoped(to: provider) + } + + func _setTokenSnapshotForTesting(_ snapshot: CostUsageTokenSnapshot?, provider: UsageProvider) { + self.tokenSnapshots[provider] = snapshot + } + + func _setTokenErrorForTesting(_ error: String?, provider: UsageProvider) { + self.tokenErrors[provider] = error + } + + func _setErrorForTesting(_ error: String?, provider: UsageProvider) { + self.errors[provider] = error + } +} +#endif diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 5876fc351..bdc972a9c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -90,26 +90,6 @@ enum ProviderStatusIndicator: String { } } -#if DEBUG -extension UsageStore { - func _setSnapshotForTesting(_ snapshot: UsageSnapshot?, provider: UsageProvider) { - self.snapshots[provider] = snapshot?.scoped(to: provider) - } - - func _setTokenSnapshotForTesting(_ snapshot: CostUsageTokenSnapshot?, provider: UsageProvider) { - self.tokenSnapshots[provider] = snapshot - } - - func _setTokenErrorForTesting(_ error: String?, provider: UsageProvider) { - self.tokenErrors[provider] = error - } - - func _setErrorForTesting(_ error: String?, provider: UsageProvider) { - self.errors[provider] = error - } -} -#endif - struct ProviderStatus { let indicator: ProviderStatusIndicator let description: String? @@ -165,8 +145,9 @@ final class UsageStore { @ObservationIgnored private var creditsFailureStreak: Int = 0 @ObservationIgnored private var lastOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? @ObservationIgnored private var lastOpenAIDashboardTargetEmail: String? + @ObservationIgnored private var lastOpenAIDashboardAttemptAt: Date? @ObservationIgnored private var lastOpenAIDashboardCookieImportAttemptAt: Date? - @ObservationIgnored private var lastOpenAIDashboardCookieImportEmail: String? + @ObservationIgnored private(set) var lastOpenAIDashboardCookieImportEmail: String? @ObservationIgnored private var openAIWebAccountDidChange: Bool = false @ObservationIgnored let codexFetcher: UsageFetcher @@ -412,6 +393,8 @@ final class UsageStore { func refresh(forceTokenUsage: Bool = false) async { guard !self.isRefreshing else { return } let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup + let providers = self.enabledProviders() + let enabledProviders = Set(providers) await ProviderRefreshContext.$current.withValue(refreshPhase) { self.isRefreshing = true @@ -420,8 +403,10 @@ final class UsageStore { self.hasCompletedInitialRefresh = true } + self.clearDisabledProviderState(enabledProviders: enabledProviders) + await withTaskGroup(of: Void.self) { group in - for provider in UsageProvider.allCases { + for provider in providers { group.addTask { await self.refreshProvider(provider) } group.addTask { await self.refreshStatus(provider) } } @@ -431,11 +416,15 @@ final class UsageStore { // Token-cost usage can be slow; run it outside the refresh group so we don't block menu updates. self.scheduleTokenRefresh(force: forceTokenUsage) - // OpenAI web scrape depends on the current Codex account email (which can change after login/account - // switch). Run this after Codex usage refresh so we don't accidentally scrape with stale credentials. - await self.refreshOpenAIDashboardIfNeeded(force: forceTokenUsage) + // Keep the OpenAI web account state in sync with the current Codex identity, but avoid loading the + // full ChatGPT dashboard on the background timer. That scrape is expensive enough to show up in + // Activity Monitor, so only run it on explicit/manual refreshes and submenu-driven stale checks. + self.syncOpenAIWebState() + if forceTokenUsage { + await self.refreshOpenAIDashboardIfNeeded(force: true) + } - if self.openAIDashboardRequiresLogin { + if forceTokenUsage, self.openAIDashboardRequiresLogin { await self.refreshProvider(.codex) await self.refreshCreditsIfNeeded() } @@ -496,6 +485,7 @@ final class UsageStore { return } + let providers = self.enabledProviders() self.tokenRefreshSequenceTask = Task(priority: .utility) { [weak self] in guard let self else { return } defer { @@ -503,7 +493,7 @@ final class UsageStore { self?.tokenRefreshSequenceTask = nil } } - for provider in UsageProvider.allCases { + for provider in providers { if Task.isCancelled { break } await self.refreshTokenUsage(provider, force: force) } @@ -744,24 +734,28 @@ extension UsageStore { } private func refreshOpenAIDashboardIfNeeded(force: Bool = false) async { - guard self.isEnabled(.codex), self.settings.codexCookieSource.isEnabled else { - self.resetOpenAIWebState() - return - } + self.syncOpenAIWebState() + guard self.isEnabled(.codex), + self.settings.openAIWebAccessEnabled, + self.settings.codexCookieSource.isEnabled + else { return } let targetEmail = self.codexAccountEmailForOpenAIDashboard() - self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail) let now = Date() let minInterval = self.openAIWebRefreshIntervalSeconds() - if !force, - !self.openAIWebAccountDidChange, - self.lastOpenAIDashboardError == nil, - let snapshot = self.lastOpenAIDashboardSnapshot, - now.timeIntervalSince(snapshot.updatedAt) < minInterval - { + let refreshGate = OpenAIWebRefreshGateContext( + force: force, + accountDidChange: self.openAIWebAccountDidChange, + lastError: self.lastOpenAIDashboardError, + lastSnapshotAt: self.lastOpenAIDashboardSnapshot?.updatedAt, + lastAttemptAt: self.lastOpenAIDashboardAttemptAt, + now: now, + refreshInterval: minInterval) + if Self.shouldSkipOpenAIWebRefresh(refreshGate) { return } + self.lastOpenAIDashboardAttemptAt = now if self.openAIWebDebugLines.isEmpty { self.resetOpenAIWebDebugLog(context: "refresh") @@ -818,6 +812,7 @@ extension UsageStore { if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) { let signedIn = dash.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown" + OpenAIDashboardFetcher.evictAllCachedWebViews() await MainActor.run { self.openAIDashboard = nil self.lastOpenAIDashboardError = [ @@ -852,8 +847,10 @@ extension UsageStore { targetEmail: targetEmail, cookieImportStatus: self.openAIDashboardCookieImportStatus) ?? OpenAIDashboardFetcher.FetchError.noDashboardData(body: finalBody).localizedDescription + OpenAIDashboardFetcher.evictAllCachedWebViews() await self.applyOpenAIDashboardFailure(message: message) } catch { + OpenAIDashboardFetcher.evictAllCachedWebViews() await self.applyOpenAIDashboardFailure(message: error.localizedDescription) } } catch OpenAIDashboardFetcher.FetchError.loginRequired { @@ -870,6 +867,7 @@ extension UsageStore { timeout: Self.openAIWebRetryFetchTimeout) await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail) } catch OpenAIDashboardFetcher.FetchError.loginRequired { + OpenAIDashboardFetcher.evictAllCachedWebViews() await MainActor.run { self.lastOpenAIDashboardError = [ "OpenAI web access requires a signed-in chatgpt.com session.", @@ -880,9 +878,11 @@ extension UsageStore { self.openAIDashboardRequiresLogin = true } } catch { + OpenAIDashboardFetcher.evictAllCachedWebViews() await self.applyOpenAIDashboardFailure(message: error.localizedDescription) } } catch { + OpenAIDashboardFetcher.evictAllCachedWebViews() await self.applyOpenAIDashboardFailure(message: error.localizedDescription) } } @@ -913,6 +913,7 @@ extension UsageStore { self.openAIDashboard = nil self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardError = nil + self.lastOpenAIDashboardAttemptAt = nil self.openAIDashboardRequiresLogin = true self.openAIDashboardCookieImportStatus = "Codex account changed; importing browser cookies…" self.lastOpenAIDashboardCookieImportAttemptAt = nil @@ -1091,35 +1092,18 @@ extension UsageStore { } func resetOpenAIWebState() { + OpenAIDashboardFetcher.evictAllCachedWebViews() self.openAIDashboard = nil self.lastOpenAIDashboardError = nil self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardTargetEmail = nil + self.lastOpenAIDashboardAttemptAt = nil self.openAIDashboardRequiresLogin = false self.openAIDashboardCookieImportStatus = nil self.openAIDashboardCookieImportDebugLog = nil self.lastOpenAIDashboardCookieImportAttemptAt = nil self.lastOpenAIDashboardCookieImportEmail = nil } - - private 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 - } } extension UsageStore { diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 4a8d9441d..1004b203e 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -275,6 +275,10 @@ public struct OpenAIDashboardFetcher { await OpenAIDashboardWebsiteDataStore.clearStore(forAccountEmail: accountEmail) } + public static func evictAllCachedWebViews() { + OpenAIDashboardWebViewCache.shared.evictAll() + } + public func probeUsagePage( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index 54aa0417a..f92b275fd 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -29,7 +29,10 @@ final class OpenAIDashboardWebViewCache { } private var entries: [ObjectIdentifier: Entry] = [:] - private let idleTimeout: TimeInterval = 10 * 60 + /// Keep the WebView alive only long enough for immediate retries/menu reopens. + /// Long-lived hidden ChatGPT tabs still consume noticeable energy on some setups. + private let idleTimeout: TimeInterval = 60 + private let blankURL = URL(string: "about:blank")! // MARK: - Testing support @@ -50,6 +53,10 @@ final class OpenAIDashboardWebViewCache { self.prune(now: now) } + var idleTimeoutForTesting: TimeInterval { + self.idleTimeout + } + /// Clear all cached entries (for test isolation). func clearAllForTesting() { for (_, entry) in self.entries { @@ -110,10 +117,7 @@ final class OpenAIDashboardWebViewCache { guard let self, let entry else { return } entry.isBusy = false entry.lastUsedAt = Date() - // Hide instead of close - keep WebView cached for reuse. - // This avoids re-downloading the ChatGPT SPA bundle on every refresh, - // saving significant network bandwidth. See GitHub issues #269, #251. - entry.host.hide() + self.prepareCachedWebViewForIdle(entry.webView, host: entry.host) self.prune(now: Date()) }) } @@ -139,10 +143,7 @@ final class OpenAIDashboardWebViewCache { guard let self, let entry else { return } entry.isBusy = false entry.lastUsedAt = Date() - // Hide instead of close - keep WebView cached for reuse. - // This avoids re-downloading the ChatGPT SPA bundle on every refresh, - // saving significant network bandwidth. See GitHub issues #269, #251. - entry.host.hide() + self.prepareCachedWebViewForIdle(webView, host: entry.host) self.prune(now: Date()) }) } @@ -154,6 +155,27 @@ final class OpenAIDashboardWebViewCache { entry.host.close() } + func evictAll() { + let existing = self.entries + self.entries.removeAll() + for (_, entry) in existing { + entry.host.close() + } + if !existing.isEmpty { + Self.log.debug("OpenAI webview evicted all") + } + } + + private func prepareCachedWebViewForIdle(_ webView: WKWebView, host: OffscreenWebViewHost) { + // Detach the heavyweight ChatGPT SPA as soon as a scrape completes. Keeping the WebView object around + // still helps with immediate reuse, but letting chatgpt.com remain the active document is too expensive. + webView.stopLoading() + webView.navigationDelegate = nil + webView.codexNavigationDelegate = nil + _ = webView.load(URLRequest(url: self.blankURL)) + host.hide() + } + private func prune(now: Date) { let expired = self.entries.filter { _, entry in !entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) > self.idleTimeout diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index c53cb7394..8403bfadb 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -6,9 +6,9 @@ import WebKit /// Tests for OpenAIDashboardWebViewCache to verify WebView reuse behavior. /// -/// Background: The cache should keep WebViews alive after use to avoid re-downloading -/// the ChatGPT SPA bundle on every refresh. Previously, WebViews were destroyed after -/// each fetch, causing 15+ GB of network traffic over time. See GitHub issues #269, #251. +/// Background: The cache should keep WebViews alive just long enough for immediate retries, but released +/// entries should blank the current page so a hidden ChatGPT tab cannot keep burning energy. See GitHub +/// issues #269, #251, #139. @MainActor @Suite(.serialized) struct OpenAIDashboardWebViewCacheTests { @@ -67,6 +67,25 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } + @Test("Released cached WebView should blank the active page") + func releasedWebViewNavigatesToBlankPage() async throws { + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "data:text/html,codexbar")) + + let lease = try await cache.acquire( + websiteDataStore: store, + usageURL: url, + logger: nil) + let webView = lease.webView + lease.release() + + try? await Task.sleep(for: .milliseconds(150)) + #expect(webView.url?.absoluteString == "about:blank", "Released WebView should not stay on the source page") + + cache.clearAllForTesting() + } + @Test("Different data stores should have separate cached WebViews") func separateCachesPerDataStore() async throws { let cache = OpenAIDashboardWebViewCache() @@ -113,8 +132,8 @@ struct OpenAIDashboardWebViewCacheTests { #expect(cache.hasCachedEntry(for: store), "Should be cached immediately after release") - // Simulate time passing beyond idle timeout (10 minutes + buffer) - let futureTime = Date().addingTimeInterval(11 * 60) + // Simulate time passing beyond idle timeout. + let futureTime = Date().addingTimeInterval(cache.idleTimeoutForTesting + 5) cache.pruneForTesting(now: futureTime) #expect(!cache.hasCachedEntry(for: store), "Should be pruned after idle timeout") @@ -134,8 +153,8 @@ struct OpenAIDashboardWebViewCacheTests { logger: nil) lease.release() - // Simulate time passing within idle timeout (5 minutes) - let nearFutureTime = Date().addingTimeInterval(5 * 60) + // Simulate time passing within idle timeout. + let nearFutureTime = Date().addingTimeInterval(max(cache.idleTimeoutForTesting / 2, 1)) cache.pruneForTesting(now: nearFutureTime) #expect(cache.hasCachedEntry(for: store), "Should still be cached within idle timeout") @@ -168,6 +187,27 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } + @Test("Evict all should remove every cached WebView") + func evictAllRemovesAllEntries() async throws { + let cache = OpenAIDashboardWebViewCache() + let store1 = WKWebsiteDataStore.nonPersistent() + let store2 = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + let lease1 = try await cache.acquire(websiteDataStore: store1, usageURL: url, logger: nil) + lease1.release() + let lease2 = try await cache.acquire(websiteDataStore: store2, usageURL: url, logger: nil) + lease2.release() + + #expect(cache.entryCount == 2, "Should have two cached entries") + + cache.evictAll() + + #expect(cache.entryCount == 0, "Evict all should remove every cached entry") + #expect(!cache.hasCachedEntry(for: store1), "First store should be evicted") + #expect(!cache.hasCachedEntry(for: store2), "Second store should be evicted") + } + // MARK: - Busy WebView Tests @Test("Busy WebView should create temporary WebView for concurrent access") diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift new file mode 100644 index 000000000..c110cfc26 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import CodexBar + +struct OpenAIWebRefreshGateTests { + @Test("Recent successful dashboard refresh stays throttled") + func recentSuccessSkipsRefresh() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: false, + accountDidChange: false, + lastError: nil, + lastSnapshotAt: now.addingTimeInterval(-60), + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == true) + } + + @Test("Recent failed dashboard refresh also stays throttled") + func recentFailureSkipsRefresh() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: false, + accountDidChange: false, + lastError: "login required", + lastSnapshotAt: nil, + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == true) + } + + @Test("Force refresh bypasses throttle after failures") + func forceRefreshBypassesCooldown() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: true, + accountDidChange: false, + lastError: "login required", + lastSnapshotAt: nil, + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == false) + } + + @Test("Account switches bypass the prior-attempt cooldown") + func accountChangeBypassesCooldown() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: false, + accountDidChange: true, + lastError: "mismatch", + lastSnapshotAt: nil, + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == false) + } +} diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index a3e3d0b61..d14179202 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -123,6 +123,49 @@ struct ProviderSettingsDescriptorTests { #expect(pickers.contains(where: { $0.id == "codex-cookie-source" })) } + @Test + func codexExposesOpenAIWebExtrasToggleAsDefaultOffOptIn() throws { + let suite = "ProviderSettingsDescriptorTests-codex-openai-toggle" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + let context = ProviderSettingsContext( + provider: .codex, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + + let toggles = CodexProviderImplementation().settingsToggles(context: context) + let toggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-extras" })) + #expect(toggle.binding.wrappedValue == false) + #expect(toggle.subtitle.contains("Optional.")) + #expect(toggle.subtitle.contains("Turn this on")) + } + @Test func claudeExposesUsageAndCookiePickers() throws { let suite = "ProviderSettingsDescriptorTests-claude" diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 04ca55516..3f50913b2 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -611,13 +611,36 @@ struct SettingsStoreTests { } @Test - func defaultsOpenAIWebAccessToEnabled() throws { + func defaultsOpenAIWebAccessToDisabled() throws { let suite = "SettingsStoreTests-openai-web" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defaults.set(false, forKey: "debugDisableKeychainAccess") let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebAccessEnabled == false) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == false) + #expect(store.codexCookieSource == .off) + } + + @Test + func infersOpenAIWebAccessEnabledForLegacyConfiguredCodexCookies() throws { + let suite = "SettingsStoreTests-openai-web-legacy" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.removeObject(forKey: "openAIWebAccessEnabled") + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + try configStore.save(CodexBarConfig(providers: [ + ProviderConfig(id: .codex, cookieSource: .auto), + ])) + let store = SettingsStore( userDefaults: defaults, configStore: configStore, @@ -629,6 +652,32 @@ struct SettingsStoreTests { #expect(store.codexCookieSource == .auto) } + @Test + func disablingOpenAIWebAccessTurnsCodexCookieSourceOff() throws { + let suite = "SettingsStoreTests-openai-web-toggle" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.codexCookieSource = .auto + #expect(store.codexCookieSource == .auto) + + store.openAIWebAccessEnabled = false + #expect(store.codexCookieSource == .off) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == false) + + store.openAIWebAccessEnabled = true + #expect(store.codexCookieSource == .auto) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + } + @Test func menuObservationTokenUpdatesOnDefaultsChange() async throws { let suite = "SettingsStoreTests-observation-defaults" diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 3f04c3ff7..994bf3a45 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -135,6 +135,7 @@ struct StatusMenuTests { settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = true let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -501,6 +502,55 @@ struct StatusMenuTests { #expect(!titles.contains("Usage breakdown")) } + @Test + func hidesOpenAIWebSubmenusWhenOpenAIWebExtrasDisabled() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = false + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + if let geminiMeta = registry.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: Date()) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let titles = Set(menu.items.map(\.title)) + #expect(!titles.contains("Credits history")) + #expect(!titles.contains("Usage breakdown")) + } + @Test func showsOpenAIWebSubmenusWhenHistoryExists() throws { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 037a7180c..18aeae832 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -134,6 +134,39 @@ struct UsageStoreCoverageTests { #expect(!UsageStore.isSubscriptionPlan("api")) } + @Test + func backgroundRefreshOnlyTracksEnabledProviders() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-refresh") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + + let store = Self.makeUsageStore(settings: settings) + let staleSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(staleSnapshot, provider: .claude) + store._setErrorForTesting("stale", provider: .claude) + store.statuses[.claude] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + + #expect(store.enabledProviders() == [.codex]) + + store.clearDisabledProviderState(enabledProviders: Set(store.enabledProviders())) + + #expect(store.snapshot(for: .claude) == nil) + #expect(store.errors[.claude] == nil) + #expect(store.statuses[.claude] == nil) + } + @Test func statusIndicatorsAndFailureGate() { #expect(!ProviderStatusIndicator.none.hasIssue) diff --git a/docs/codex.md b/docs/codex.md index c7250939a..00faef8cd 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -32,7 +32,10 @@ Usage source picker: - Refreshes access tokens when `last_refresh` is older than 8 days. - Calls `GET https://chatgpt.com/backend-api/wham/usage` (default) with `Authorization: Bearer `. -### OpenAI web dashboard (optional) +### OpenAI web dashboard (optional, off by default) +- Enable it in Preferences -> Providers -> Codex -> OpenAI web extras. +- It exists for dashboard-only extras such as code review remaining, usage breakdown, and credits history. +- It is intentionally opt-in because it loads `chatgpt.com` in a hidden WebView and can materially increase battery or network usage. - Preferences → Providers → Codex → OpenAI cookies (Automatic or Manual). - URL: `https://chatgpt.com/codex/settings/usage`. - Uses an off-screen `WKWebView` with a per-account `WKWebsiteDataStore`. diff --git a/docs/providers.md b/docs/providers.md index 99da2542d..a427f05bd 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -40,7 +40,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | OpenRouter | API token (config, overrides env) → credits API (`api`). | ## Codex -- Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. +- Web dashboard (optional, off by default): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. - CLI RPC default: `codex ... app-server` JSON-RPC (`account/read`, `account/rateLimits/read`). - CLI PTY fallback: `/status` scrape. - Local cost usage: scans `~/.codex/sessions/**/*.jsonl` (last 30 days). diff --git a/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md b/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md new file mode 100644 index 000000000..7daf7fc7c --- /dev/null +++ b/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md @@ -0,0 +1,66 @@ +--- +module: CodexBar +date: 2026-03-07 +problem_type: performance_issue +component: tooling +symptoms: + - "Hidden chatgpt.com web content could spike to extremely high Energy Impact values in Activity Monitor" + - "CodexBar battery usage stayed abnormally high even when the app appeared idle" + - "Users did not realize optional OpenAI web extras were enabled by default" +root_cause: wrong_api +resolution_type: config_change +severity: high +tags: [codexbar, battery-drain, openai-web, webview, chatgpt, defaults] +--- + +# Troubleshooting: Default OpenAI Web Extras Off + +## Problem +CodexBar exposed optional OpenAI dashboard extras through a hidden `chatgpt.com` WebView, but the feature was enabled by default. That created a mismatch between user expectations for a lightweight menu bar app and the real cost of running a hidden single-page web app in the background. + +## Environment +- Module: CodexBar +- Affected component: Codex OpenAI web extras +- Date: 2026-03-07 + +## Symptoms +- Activity Monitor showed extreme energy usage attributed to `https://chatgpt.com` under the CodexBar process tree. +- Users observed battery drain that was out of proportion to the visible work the app was doing. +- The optional setting existed, but it was easy to miss, so affected users often did not know they could disable it. + +## What Didn't Work + +**Attempted solution 1:** Throttle failed OpenAI dashboard refresh attempts and evict cached WebViews more aggressively. +- **Why it failed:** This reduced the runaway failure loop, but it did not change the product default. Users could still pay the cost of a hidden ChatGPT dashboard without explicitly opting into it. + +**Attempted solution 2:** Keep the feature enabled by default and rely on a visible opt-out toggle. +- **Why it failed:** The battery and network cost was too high for a background utility. An opt-out-only design still left many users exposed to behavior they did not expect or understand. + +## Solution +Change OpenAI web extras to be off by default for new installs while preserving existing explicit configurations. + +**Code changes** +- `SettingsStore` now defaults `openAIWebAccessEnabled` to `false` when no prior preference exists. +- Existing users with an explicit Codex cookie configuration are inferred as enabled so upgrades do not silently break working setups. +- The Codex settings copy now describes the feature as optional and warns about battery and network cost. +- Documentation now labels the OpenAI web dashboard path as optional and off by default. + +## Why This Works +The root problem was not that the app had a toggle. The root problem was that an optional feature with heavyweight implementation details was enabled by default. + +The OpenAI web extras path uses a hidden `WKWebView` against `chatgpt.com` to gather dashboard-only data. That mechanism is fundamentally more expensive than the main Codex data paths, which already provide the normal information users expect from the app: session usage, weekly usage, reset timers, account identity, plan label, and normal credits remaining. + +Making the feature opt-in aligns the default behavior with the actual technical cost: +1. The normal Codex card continues to work without the hidden ChatGPT dashboard. +2. Users only incur the WebView cost if they deliberately choose the extra dashboard data. +3. Existing users with a configured Codex web setup keep their behavior on upgrade instead of being silently broken. + +## Prevention +- Do not default-enable optional features that load heavyweight hidden web content in a background utility. +- If a feature depends on a hidden SPA or WebView, require explicit user opt-in unless it is essential to core functionality. +- Prefer direct API or cookie-backed HTTP requests over hidden browser automation for background data collection. +- Surface the operational cost of optional features in the settings copy, not only in debug notes or issue threads. + +## Related Issues +- See also: [perf-energy-issue-139-simulation-report-2026-02-19.md](../../perf-energy-issue-139-simulation-report-2026-02-19.md) +- See also: [perf-energy-issue-139-main-fix-validation-2026-02-19.md](../../perf-energy-issue-139-main-fix-validation-2026-02-19.md) From b6f4adc0cad71a86d1eab958f3efe5b84a7327e5 Mon Sep 17 00:00:00 2001 From: cbrane Date: Sun, 15 Mar 2026 17:26:47 -0400 Subject: [PATCH 2/4] Make OpenAI web refresh discoverable --- Sources/CodexBar/MenuDescriptor.swift | 1 + .../PreferencesProvidersPane+Testing.swift | 9 +++++ .../CodexBar/PreferencesProvidersPane.swift | 37 ++++++++++++++++--- .../ProvidersPaneCoverageTests.swift | 25 +++++++++++++ Tests/CodexBarTests/StatusMenuTests.swift | 1 + 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 8e7972a20..ac367b127 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -361,6 +361,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), diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index e2dce0a7f..491475d6f 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -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 diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 877c78da9..50b3b646c 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -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") @@ -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 { let meta = self.store.metadata(for: provider) return Binding( diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index e5ea668de..a453a07c9 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -47,6 +47,31 @@ struct ProvidersPaneCoverageTests { #expect(row?.value == "Pro") } + @Test + func codexRefreshUsesExplicitPathWhenOpenAIWebExtrasEnabled() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-codex-refresh-full") + settings.openAIWebAccessEnabled = true + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + #expect(pane._test_refreshAction(for: .codex) == "fullStore") + #expect(pane._test_refreshAction(for: .claude) == "providerOnly") + } + + @Test + func disabledCodexRefreshStaysProviderScoped() throws { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-codex-refresh-disabled") + settings.openAIWebAccessEnabled = true + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + let metadata = ProviderRegistry.shared.metadata + + let codexMetadata = try #require(metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: false) + + #expect(pane._test_refreshAction(for: .codex) == "providerOnly") + } + private static func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 994bf3a45..027d5aa8d 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -412,6 +412,7 @@ struct StatusMenuTests { #expect(!titles.contains("Switch Account...")) #expect(!titles.contains("Usage Dashboard")) #expect(!titles.contains("Status Page")) + #expect(titles.contains("Refresh")) #expect(titles.contains("Settings...")) #expect(titles.contains("About CodexBar")) #expect(titles.contains("Quit")) From 8343a6f65d300f25a3c85967c1d440a2d351a2c9 Mon Sep 17 00:00:00 2001 From: cbrane Date: Fri, 20 Mar 2026 18:16:09 -0400 Subject: [PATCH 3/4] Separate OpenAI web extras from battery saver --- .../Codex/CodexProviderImplementation.swift | 17 ++++++++++- Sources/CodexBar/SettingsStore+Defaults.swift | 11 +++++++ .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 4 +++ Sources/CodexBar/SettingsStoreState.swift | 1 + Sources/CodexBar/UsageStore+Logging.swift | 1 + Sources/CodexBar/UsageStore+OpenAIWeb.swift | 11 +++++++ Sources/CodexBar/UsageStore.swift | 16 ++++++---- .../OpenAIWebRefreshGateTests.swift | 30 +++++++++++++++++++ .../ProviderSettingsDescriptorTests.swift | 16 +++++++--- Tests/CodexBarTests/SettingsStoreTests.swift | 27 +++++++++++++++++ 11 files changed, 125 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 1f0ccf306..d98e56282 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -69,6 +69,7 @@ struct CodexProviderImplementation: ProviderImplementation { for: .codex) } }) + let batterySaverBinding = context.boolBinding(\.openAIWebBatterySaverEnabled) return [ ProviderSettingsToggleDescriptor( @@ -88,7 +89,6 @@ struct CodexProviderImplementation: ProviderImplementation { subtitle: [ "Optional.", "Turn this on to show code review, usage breakdown, and credits history via chatgpt.com.", - "This may increase battery or network usage.", ].joined(separator: " "), binding: extrasBinding, statusText: nil, @@ -97,6 +97,21 @@ struct CodexProviderImplementation: ProviderImplementation { 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), ] } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 44d83a023..b0456c9a6 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -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 { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index f01cc49fa..24f5a5c7b 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -27,6 +27,7 @@ extension SettingsStore { _ = self.claudeWebExtrasEnabled _ = self.showOptionalCreditsAndExtraUsage _ = self.openAIWebAccessEnabled + _ = self.openAIWebBatterySaverEnabled _ = self.codexUsageDataSource _ = self.claudeUsageDataSource _ = self.kiloUsageDataSource diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 8937df145..d8b646526 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -225,6 +225,9 @@ extension SettingsStore { let openAIWebAccessDefault = userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool let openAIWebAccessEnabled = openAIWebAccessDefault ?? false if openAIWebAccessDefault == nil { userDefaults.set(false, forKey: "openAIWebAccessEnabled") } + let openAIWebBatterySaverDefault = userDefaults.object(forKey: "openAIWebBatterySaverEnabled") as? Bool + let openAIWebBatterySaverEnabled = openAIWebBatterySaverDefault ?? true + 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 @@ -262,6 +265,7 @@ extension SettingsStore { claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, openAIWebAccessEnabled: openAIWebAccessEnabled, + openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, mergeIcons: mergeIcons, switcherShowsIcons: switcherShowsIcons, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 98e01406d..69e676032 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -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 diff --git a/Sources/CodexBar/UsageStore+Logging.swift b/Sources/CodexBar/UsageStore+Logging.swift index a598da99a..e4c3b7b24 100644 --- a/Sources/CodexBar/UsageStore+Logging.swift +++ b/Sources/CodexBar/UsageStore+Logging.swift @@ -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", ] diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 60d686dbe..718723d81 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -10,9 +10,20 @@ struct OpenAIWebRefreshGateContext { 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 shouldSkipOpenAIWebRefresh(_ context: OpenAIWebRefreshGateContext) -> Bool { if context.force || context.accountDidChange { return false } if let lastAttemptAt = context.lastAttemptAt, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index a83b0fedc..c4e37c13d 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -416,12 +416,18 @@ final class UsageStore { // Token-cost usage can be slow; run it outside the refresh group so we don't block menu updates. self.scheduleTokenRefresh(force: forceTokenUsage) - // Keep the OpenAI web account state in sync with the current Codex identity, but avoid loading the - // full ChatGPT dashboard on the background timer. That scrape is expensive enough to show up in - // Activity Monitor, so only run it on explicit/manual refreshes and submenu-driven stale checks. + // Keep the OpenAI web account state in sync with the current Codex identity. Background dashboard + // scraping stays opt-in behind the Battery Saver setting because the full ChatGPT page is expensive + // enough to show up in Activity Monitor. self.syncOpenAIWebState() - if forceTokenUsage { - await self.refreshOpenAIDashboardIfNeeded(force: true) + let refreshPolicy = OpenAIWebRefreshPolicyContext( + accessEnabled: self.isEnabled(.codex) && + self.settings.openAIWebAccessEnabled && + self.settings.codexCookieSource.isEnabled, + batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled, + force: forceTokenUsage) + if Self.shouldRunOpenAIWebRefresh(refreshPolicy) { + await self.refreshOpenAIDashboardIfNeeded(force: forceTokenUsage) } if forceTokenUsage, self.openAIDashboardRequiresLogin { diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift index c110cfc26..8a481b84f 100644 --- a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -3,6 +3,36 @@ import Testing @testable import CodexBar struct OpenAIWebRefreshGateTests { + @Test("Battery saver keeps background OpenAI web refreshes off") + func batterySaverDisablesBackgroundRefresh() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: true, + force: false)) + + #expect(shouldRun == false) + } + + @Test("Disabling battery saver restores normal OpenAI web refreshes") + func disabledBatterySaverAllowsBackgroundRefresh() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: false, + force: false)) + + #expect(shouldRun == true) + } + + @Test("Manual refresh still forces OpenAI web refreshes with battery saver enabled") + func manualRefreshBypassesBatterySaver() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: true, + force: true)) + + #expect(shouldRun == true) + } + @Test("Recent successful dashboard refresh stays throttled") func recentSuccessSkipsRefresh() { let now = Date() diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index c9c3031c1..714fa2734 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -161,10 +161,18 @@ struct ProviderSettingsDescriptorTests { requestConfirmation: { _ in }) let toggles = CodexProviderImplementation().settingsToggles(context: context) - let toggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-extras" })) - #expect(toggle.binding.wrappedValue == false) - #expect(toggle.subtitle.contains("Optional.")) - #expect(toggle.subtitle.contains("Turn this on")) + let extrasToggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-extras" })) + #expect(extrasToggle.binding.wrappedValue == false) + #expect(extrasToggle.subtitle.contains("Optional.")) + #expect(extrasToggle.subtitle.contains("Turn this on")) + + let batterySaverToggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-battery-saver" })) + #expect(batterySaverToggle.binding.wrappedValue == true) + #expect(batterySaverToggle.subtitle.contains("Recommended.")) + #expect(batterySaverToggle.isVisible?() == false) + + settings.openAIWebAccessEnabled = true + #expect(batterySaverToggle.isVisible?() == true) } @Test diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 1188a406d..f93152250 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -627,6 +627,8 @@ struct SettingsStoreTests { #expect(store.openAIWebAccessEnabled == false) #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == false) + #expect(store.openAIWebBatterySaverEnabled == true) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == true) #expect(store.codexCookieSource == .off) } @@ -650,6 +652,8 @@ struct SettingsStoreTests { #expect(store.openAIWebAccessEnabled == true) #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + #expect(store.openAIWebBatterySaverEnabled == true) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == true) #expect(store.codexCookieSource == .auto) } @@ -679,6 +683,29 @@ struct SettingsStoreTests { #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) } + @Test + func openAIWebBatterySaverPersistsSeparatelyFromExtrasAvailability() throws { + let suite = "SettingsStoreTests-openai-web-battery-saver" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebBatterySaverEnabled == true) + + store.openAIWebBatterySaverEnabled = false + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + + store.openAIWebAccessEnabled = true + #expect(store.openAIWebBatterySaverEnabled == false) + } + @Test func `menu observation token updates on defaults change`() async throws { let suite = "SettingsStoreTests-observation-defaults" From 381498ff8dcfe70d323ae982dc4fcbc6c1b53e2a Mon Sep 17 00:00:00 2001 From: cbrane Date: Sun, 22 Mar 2026 14:59:19 -0400 Subject: [PATCH 4/4] Respect battery saver on stale dashboard refresh --- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 4 ++++ Sources/CodexBar/UsageStore.swift | 4 +++- .../CodexBarTests/OpenAIWebRefreshGateTests.swift | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 718723d81..f4d8b16c6 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -24,6 +24,10 @@ extension UsageStore { 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, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index c4e37c13d..f7977994f 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -694,7 +694,9 @@ extension UsageStore { if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) < refreshInterval { return } let stamp = now.formatted(date: .abbreviated, time: .shortened) self.logOpenAIWeb("[\(stamp)] OpenAI web refresh request: \(reason)") - Task { await self.refreshOpenAIDashboardIfNeeded(force: true) } + let forceRefresh = Self.forceOpenAIWebRefreshForStaleRequest( + batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled) + Task { await self.refreshOpenAIDashboardIfNeeded(force: forceRefresh) } } private func applyOpenAIDashboard(_ dash: OpenAIDashboardSnapshot, targetEmail: String?) async { diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift index 8a481b84f..67c7282cc 100644 --- a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -33,6 +33,20 @@ struct OpenAIWebRefreshGateTests { #expect(shouldRun == true) } + @Test("Battery saver stale-submenu refresh respects the cooldown") + func batterySaverStaleRefreshDoesNotForce() { + let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: true) + + #expect(shouldForce == false) + } + + @Test("Normal stale-submenu refresh still forces when battery saver is off") + func nonBatterySaverStaleRefreshForces() { + let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: false) + + #expect(shouldForce == true) + } + @Test("Recent successful dashboard refresh stays throttled") func recentSuccessSkipsRefresh() { let now = Date()