diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 75cf7e29d..68c81a10a 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -400,6 +400,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.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index c95f40431..9e8a63640 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -66,15 +66,7 @@ struct ProvidersPane: View { isErrorExpanded: self.expandedBinding(for: provider), onCopyError: { text in self.copyToPasteboard(text) }, onRefresh: { - Task { @MainActor in - await ProviderInteractionContext.$current.withValue(.userInitiated) { - if provider == .codex { - await self.store.refreshCodexAccountScopedState(allowDisabled: true) - } else { - await self.store.refreshProvider(provider, allowDisabled: true) - } - } - } + self.triggerRefresh(for: provider) }, showsSupplementarySettingsContent: self.codexAccountsSectionState(for: provider) != nil, supplementarySettingsContent: { @@ -156,6 +148,18 @@ struct ProvidersPane: View { self.selectedProvider = self.providers.first } + private func triggerRefresh(for provider: UsageProvider) { + Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + if provider == .codex { + await self.store.refreshCodexAccountScopedState(allowDisabled: true) + } else { + 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/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 2f306aa55..ad1a0f2fc 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( @@ -85,7 +86,10 @@ 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: [], @@ -93,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 477529804..84aebdd9f 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -268,6 +268,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 9c3a5095a..5ac7f16f6 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.codexActiveSource _ = self.claudeUsageDataSource diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index c3a59ba7a..68ba707aa 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -124,6 +124,8 @@ final class SettingsStore { copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore(), tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore()) { + let hasStoredOpenAIWebAccessPreference = userDefaults.object(forKey: "openAIWebAccessEnabled") != nil + let hadExistingConfig = (try? configStore.load()) != nil let legacyStores = CodexBarConfigMigrator.LegacyStores( zaiTokenStore: zaiTokenStore, syntheticTokenStore: syntheticTokenStore, @@ -159,7 +161,13 @@ 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, + hadExistingConfig: hadExistingConfig) + } if Self.shouldBridgeSharedDefaults(for: userDefaults) { Self.sharedDefaults?.set(self.debugDisableKeychainAccess, forKey: "debugDisableKeychainAccess") } @@ -168,6 +176,16 @@ final class SettingsStore { } extension SettingsStore { + private static func inferredInitialOpenAIWebAccessEnabled( + config: CodexBarConfig, + hadExistingConfig: Bool) -> Bool + { + guard let codex = config.providerConfig(for: .codex) else { return false } + if let cookieSource = codex.cookieSource { return cookieSource.isEnabled } + if codex.sanitizedCookieHeader != nil { return true } + return hadExistingConfig + } + private static func loadDefaultsState(userDefaults: UserDefaults) -> SettingsDefaultsState { let refreshDefault = userDefaults.string(forKey: "refreshFrequency") .flatMap(RefreshFrequency.init(rawValue:)) @@ -230,8 +248,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 ?? false + if openAIWebBatterySaverDefault == nil { userDefaults.set(false, 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 @@ -269,6 +290,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+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+Logging.swift b/Sources/CodexBar/UsageStore+Logging.swift index 06dbc92c9..d5c9830d0 100644 --- a/Sources/CodexBar/UsageStore+Logging.swift +++ b/Sources/CodexBar/UsageStore+Logging.swift @@ -18,6 +18,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 65fb3ce97..08a15e4a6 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -1,6 +1,22 @@ import CodexBarCore 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 lifecycle extension UsageStore { @@ -31,15 +47,28 @@ extension UsageStore { } func requestOpenAIDashboardRefreshIfStale(reason: String) { - guard self.isEnabled(.codex), self.settings.codexCookieSource.isEnabled else { return } + guard self.isEnabled(.codex), + self.settings.openAIWebAccessEnabled, + self.settings.codexCookieSource.isEnabled + else { return } let now = Date() let refreshInterval = self.openAIWebRefreshIntervalSeconds() let lastUpdatedAt = self.openAIDashboard?.updatedAt ?? self.lastOpenAIDashboardSnapshot?.updatedAt if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) < refreshInterval { return } let stamp = now.formatted(date: .abbreviated, time: .shortened) self.logOpenAIWeb("[\(stamp)] OpenAI web refresh request: \(reason)") + let forceRefresh = Self.forceOpenAIWebRefreshForStaleRequest( + batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled) + self.openAIWebLogger.debug( + "OpenAI web stale refresh gate", + metadata: [ + "reason": reason, + "force": forceRefresh ? "1" : "0", + "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", + "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", + ]) let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() - Task { await self.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) } + Task { await self.refreshOpenAIDashboardIfNeeded(force: forceRefresh, expectedGuard: expectedGuard) } } func applyOpenAIDashboard( @@ -95,6 +124,7 @@ extension UsageStore { return } + OpenAIDashboardFetcher.evictAllCachedWebViews() await MainActor.run { if let cached = self.lastOpenAIDashboardSnapshot { self.openAIDashboard = cached @@ -132,6 +162,7 @@ extension UsageStore { return } + OpenAIDashboardFetcher.evictAllCachedWebViews() await MainActor.run { self.lastOpenAIDashboardError = [ "OpenAI web access requires a signed-in chatgpt.com session.", @@ -311,10 +342,11 @@ extension UsageStore { bypassCoalescing: Bool = false, allowCodexUsageBackfill: Bool = true) 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 } if self.openAIWebManagedTargetStoreIsUnreadable() { await self.failClosedRefreshForUnreadableManagedCodexStore() return @@ -341,14 +373,18 @@ extension UsageStore { 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 let taskToken = UUID() let context = OpenAIDashboardRefreshContext( @@ -610,6 +646,7 @@ extension UsageStore { self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardAttachmentAuthorized = false self.lastOpenAIDashboardError = nil + self.lastOpenAIDashboardAttemptAt = nil self.openAIDashboardRequiresLogin = true self.openAIDashboardCookieImportStatus = "Codex account changed; importing browser cookies…" self.lastOpenAIDashboardCookieImportAttemptAt = nil @@ -994,12 +1031,14 @@ extension UsageStore { func resetOpenAIWebState() { self.invalidateOpenAIDashboardRefreshTask() + OpenAIDashboardFetcher.evictAllCachedWebViews() self.openAIDashboard = nil self.openAIDashboardAttachmentAuthorized = false self.lastOpenAIDashboardError = nil self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardAttachmentAuthorized = false self.lastOpenAIDashboardTargetEmail = nil + self.lastOpenAIDashboardAttemptAt = nil self.openAIDashboardRequiresLogin = false self.openAIDashboardCookieImportStatus = nil self.openAIDashboardCookieImportDebugLog = nil @@ -1079,6 +1118,46 @@ extension UsageStore { // 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.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: true, + allowLastKnownLiveFallback: true) + self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail) + } + func openAIDashboardFriendlyError( body: String, targetEmail: String?, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 42acabb03..0ecbc1820 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -137,6 +137,7 @@ final class UsageStore { @ObservationIgnored var lastOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? @ObservationIgnored var lastOpenAIDashboardAttachmentAuthorized: Bool = false @ObservationIgnored var lastOpenAIDashboardTargetEmail: String? + @ObservationIgnored var lastOpenAIDashboardAttemptAt: Date? @ObservationIgnored var lastOpenAIDashboardCookieImportAttemptAt: Date? @ObservationIgnored var lastOpenAIDashboardCookieImportEmail: String? @ObservationIgnored var lastCodexAccountScopedRefreshGuard: CodexAccountScopedRefreshGuard? @@ -435,6 +436,8 @@ final class UsageStore { guard !self.isRefreshing else { return } self.prepareRefreshState() let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup + let enabledProviders = self.enabledProvidersForDisplay() + let enabledProviderSet = Set(enabledProviders) let refreshStartedAt = Date() await ProviderRefreshContext.$current.withValue(refreshPhase) { @@ -444,8 +447,10 @@ final class UsageStore { self.hasCompletedInitialRefresh = true } + self.clearDisabledProviderState(enabledProviders: enabledProviderSet) + await withTaskGroup(of: Void.self) { group in - for provider in UsageProvider.allCases { + for provider in enabledProviders { group.addTask { await self.refreshProvider(provider) } group.addTask { await self.refreshStatus(provider) } } @@ -457,12 +462,32 @@ final class UsageStore { // 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. - let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() - await self.refreshOpenAIDashboardIfNeeded( - force: forceTokenUsage, - expectedGuard: codexDashboardGuard) + self.syncOpenAIWebState() + let refreshPolicy = OpenAIWebRefreshPolicyContext( + accessEnabled: self.isEnabled(.codex) && + self.settings.openAIWebAccessEnabled && + self.settings.codexCookieSource.isEnabled, + batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled, + force: forceTokenUsage) + let shouldRefreshOpenAIWeb = Self.shouldRunOpenAIWebRefresh(refreshPolicy) + self.openAIWebLogger.debug( + "OpenAI web refresh gate", + metadata: [ + "allowed": shouldRefreshOpenAIWeb ? "1" : "0", + "accessEnabled": refreshPolicy.accessEnabled ? "1" : "0", + "batterySaverEnabled": refreshPolicy.batterySaverEnabled ? "1" : "0", + "force": refreshPolicy.force ? "1" : "0", + "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", + "phase": refreshPhase == .startup ? "startup" : "regular", + ]) + if shouldRefreshOpenAIWeb { + let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() + await self.refreshOpenAIDashboardIfNeeded( + force: forceTokenUsage, + expectedGuard: codexDashboardGuard) + } - if self.openAIDashboardRequiresLogin { + if forceTokenUsage, self.openAIDashboardRequiresLogin { await self.refreshProvider(.codex) await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } @@ -523,6 +548,7 @@ final class UsageStore { return } + let providers = self.enabledProvidersForDisplay() self.tokenRefreshSequenceTask = Task(priority: .utility) { [weak self] in guard let self else { return } defer { @@ -530,7 +556,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) } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index febf39829..64ca88b8d 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -309,6 +309,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 fa2d2dcab..e7c9291e1 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 + } + /// Seed a cached entry without navigating a real page (for test stability). @discardableResult func cacheEntryForTesting( @@ -173,10 +180,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()) }) } @@ -213,10 +217,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()) }) } @@ -228,6 +229,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/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift index 4c9332184..d2d139a9a 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift @@ -338,45 +338,47 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { func `experimental strategy does not use security framework fingerprint observation`() async { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } - await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental) - { - final class CounterBox: @unchecked Sendable { - private let lock = NSLock() - private(set) var count: Int = 0 - func increment() { - self.lock.lock() - self.count += 1 - self.lock.unlock() - } - } - let fingerprintCounter = CounterBox() - let securityData = self.makeCredentialsData( - accessToken: "security-token-a", - expiresAt: Date(timeIntervalSinceNow: 3600)) - let outcome = await self.withCoordinatorOverrides( - cliAvailable: true, - touchAuthPath: { _, _ in }, - keychainFingerprint: { - fingerprintCounter.increment() - return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 1, - createdAt: 1, - persistentRefHash: "framework-fingerprint") - }, - operation: { - await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(securityData)) { - await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 60000), - timeout: 0.1) + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + final class CounterBox: @unchecked Sendable { + private let lock = NSLock() + private(set) var count: Int = 0 + func increment() { + self.lock.lock() + self.count += 1 + self.lock.unlock() } - }) + } + let fingerprintCounter = CounterBox() + let securityData = self.makeCredentialsData( + accessToken: "security-token-a", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let outcome = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in }, + keychainFingerprint: { + fingerprintCounter.increment() + return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "framework-fingerprint") + }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(securityData)) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 60000), + timeout: 0.1) + } + }) - guard case .attemptedFailed = outcome else { - Issue.record("Expected .attemptedFailed outcome") - return + guard case .attemptedFailed = outcome else { + Issue.record("Expected .attemptedFailed outcome") + return + } + #expect(fingerprintCounter.count < 1) } - #expect(fingerprintCounter.count < 1) } } @@ -416,6 +418,7 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { self.lock.unlock() } } + let fingerprintCounter = CounterBox() let beforeData = self.makeCredentialsData( accessToken: "security-token-before", expiresAt: Date(timeIntervalSinceNow: -60)) @@ -423,7 +426,6 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { accessToken: "security-token-after", expiresAt: Date(timeIntervalSinceNow: 3600)) let dataBox = DataBox(data: beforeData) - let fingerprintCounter = CounterBox() let outcome = await self.withCoordinatorOverrides( cliAvailable: true, touchAuthPath: { _, _ in @@ -456,69 +458,71 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { func `experimental strategy missing baseline does not auto succeed when later read succeeds`() async { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } - await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental) - { - final class DataBox: @unchecked Sendable { - private let lock = NSLock() - private var _data: Data? - init(data: Data?) { - self._data = data - } + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + final class DataBox: @unchecked Sendable { + private let lock = NSLock() + private var _data: Data? + init(data: Data?) { + self._data = data + } - func load() -> Data? { - self.lock.lock() - defer { self.lock.unlock() } - return self._data - } + func load() -> Data? { + self.lock.lock() + defer { self.lock.unlock() } + return self._data + } - func store(_ data: Data?) { - self.lock.lock() - self._data = data - self.lock.unlock() - } - } - final class CounterBox: @unchecked Sendable { - private let lock = NSLock() - private(set) var count: Int = 0 - func increment() { - self.lock.lock() - self.count += 1 - self.lock.unlock() + func store(_ data: Data?) { + self.lock.lock() + self._data = data + self.lock.unlock() + } } - } - let afterData = self.makeCredentialsData( - accessToken: "security-token-after-baseline-miss", - expiresAt: Date(timeIntervalSinceNow: 3600)) - let dataBox = DataBox(data: nil) - let fingerprintCounter = CounterBox() - let outcome = await self.withCoordinatorOverrides( - cliAvailable: true, - touchAuthPath: { _, _ in - dataBox.store(afterData) - }, - keychainFingerprint: { - fingerprintCounter.increment() - return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 21, - createdAt: 21, - persistentRefHash: "framework-fingerprint") - }, - operation: { - await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( - .dynamic { _ in dataBox.load() }) - { - await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 61500), - timeout: 0.1) + final class CounterBox: @unchecked Sendable { + private let lock = NSLock() + private(set) var count: Int = 0 + func increment() { + self.lock.lock() + self.count += 1 + self.lock.unlock() } - }) + } + let fingerprintCounter = CounterBox() + let afterData = self.makeCredentialsData( + accessToken: "security-token-after-baseline-miss", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let dataBox = DataBox(data: nil) + let outcome = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in + dataBox.store(afterData) + }, + keychainFingerprint: { + fingerprintCounter.increment() + return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 21, + createdAt: 21, + persistentRefHash: "framework-fingerprint") + }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in dataBox.load() }) + { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 61500), + timeout: 0.1) + } + }) - guard case .attemptedFailed = outcome else { - Issue.record("Expected .attemptedFailed outcome when baseline is unavailable") - return + guard case .attemptedFailed = outcome else { + Issue.record("Expected .attemptedFailed outcome when baseline is unavailable") + return + } + #expect(fingerprintCounter.count < 1) } - #expect(fingerprintCounter.count < 1) } } diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index 4f3d61f9f..6261162b3 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -565,6 +565,8 @@ struct CodexAccountScopedRefreshTests { func `default dashboard refresh path discards stale completion after account switch`() async { let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-dashboard-guard") settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto settings._test_liveSystemCodexAccount = self.liveAccount(email: "alpha@example.com") let store = self.makeUsageStore(settings: settings) diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index 7bc9bf6b2..cb8ccb90d 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -232,11 +232,14 @@ struct CodexManagedOpenAIWebRefreshTests { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) - return SettingsStore( + let settings = SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + return settings } } diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift index 3e5c3a070..c8c6c103d 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift @@ -95,6 +95,8 @@ extension CodexManagedOpenAIWebTests { settings._test_managedCodexAccountStoreURL = nil settings._test_liveSystemCodexAccount = nil settings._test_codexReconciliationEnvironment = nil + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto return settings } diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index 43439a598..84d500fb2 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -12,10 +12,16 @@ import WebKit @MainActor @Suite(.serialized) struct OpenAIDashboardWebViewCacheTests { + private func shouldSkipOnCI() -> Bool { + let env = ProcessInfo.processInfo.environment + return env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" + } + // MARK: - Data Store Identity Tests @Test func `WKWebsiteDataStore should return same instance for same email`() { + if self.shouldSkipOnCI() { return } OpenAIDashboardWebsiteDataStore.clearCacheForTesting() let store1 = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: "test@example.com") @@ -36,6 +42,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `WebView should be cached after release, not destroyed`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -69,6 +76,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Different data stores should have separate cached WebViews`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store1 = WKWebsiteDataStore.nonPersistent() let store2 = WKWebsiteDataStore.nonPersistent() @@ -100,14 +108,15 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `WebView should be pruned after idle timeout`() { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() cache.cacheEntryForTesting(websiteDataStore: store) #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 the configured idle timeout. + let futureTime = Date().addingTimeInterval(cache.idleTimeoutForTesting + 5) cache.pruneForTesting(now: futureTime) #expect(!cache.hasCachedEntry(for: store), "Should be pruned after idle timeout") @@ -116,12 +125,13 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Recently used WebView should not be pruned`() { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() cache.cacheEntryForTesting(websiteDataStore: store) - // Simulate time passing within idle timeout (5 minutes) - let nearFutureTime = Date().addingTimeInterval(5 * 60) + // Simulate time passing comfortably within the configured idle timeout. + let nearFutureTime = Date().addingTimeInterval(max(1, cache.idleTimeoutForTesting / 2)) cache.pruneForTesting(now: nearFutureTime) #expect(cache.hasCachedEntry(for: store), "Should still be cached within idle timeout") @@ -132,6 +142,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Evict should remove specific WebView from cache`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store1 = WKWebsiteDataStore.nonPersistent() let store2 = WKWebsiteDataStore.nonPersistent() @@ -157,6 +168,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Evicted WebView should not be reused on next acquire`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -176,10 +188,33 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } + @Test("Evict all should remove every cached WebView") + func evictAllRemovesAllEntries() async throws { + if self.shouldSkipOnCI() { return } + 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 func `Busy WebView should create temporary WebView for concurrent access`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -215,6 +250,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Multiple sequential fetches should reuse same WebView (network optimization)`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -249,6 +285,7 @@ struct OpenAIDashboardWebViewCacheTests { @Test func `Sequential fetches with OpenAIDashboardWebsiteDataStore should reuse WebView`() async throws { + if self.shouldSkipOnCI() { return } OpenAIDashboardWebsiteDataStore.clearCacheForTesting() let cache = OpenAIDashboardWebViewCache() let url = try #require(URL(string: "about:blank")) diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift new file mode 100644 index 000000000..67c7282cc --- /dev/null +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -0,0 +1,113 @@ +import Foundation +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("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() + + 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 924da2a05..42db664f5 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -124,6 +124,57 @@ struct ProviderSettingsDescriptorTests { #expect(toggles.contains(where: { $0.id == "codex-historical-tracking" })) } + @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 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 == false) + #expect(batterySaverToggle.subtitle.contains("Recommended.")) + #expect(batterySaverToggle.isVisible?() == false) + + settings.openAIWebAccessEnabled = true + #expect(batterySaverToggle.isVisible?() == true) + } + @Test func `claude exposes usage and cookie pickers`() throws { let suite = "ProviderSettingsDescriptorTests-claude" diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 08fd2ecfe..a2f674271 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -649,13 +649,63 @@ struct SettingsStoreTests { } @Test - func `defaults open AI web access to enabled`() 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.openAIWebBatterySaverEnabled == false) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == 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, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebAccessEnabled == true) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + #expect(store.openAIWebBatterySaverEnabled == false) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + #expect(store.codexCookieSource == .auto) + } + + @Test + func infersOpenAIWebAccessEnabledForLegacyCodexConfigWithImplicitAutoCookies() throws { + let suite = "SettingsStoreTests-openai-web-legacy-implicit-auto" + 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), + ])) + let store = SettingsStore( userDefaults: defaults, configStore: configStore, @@ -664,9 +714,60 @@ struct SettingsStoreTests { #expect(store.openAIWebAccessEnabled == true) #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + #expect(store.openAIWebBatterySaverEnabled == false) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) #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 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 == false) + + 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" diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index eca3810fc..b0b676848 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -194,6 +194,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] { @@ -517,11 +518,15 @@ 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")) } +} +@MainActor +extension StatusMenuTests { @Test func `status blurb uses wrapped view-backed menu item`() { self.disableMenuCardsForTesting() @@ -648,7 +653,56 @@ struct StatusMenuTests { } @Test - func `shows open AI web submenus when history exists`() throws { + 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() let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusMenuTests-history"), @@ -658,6 +712,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] { diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 67b0a323a..49dd6e681 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -134,7 +134,77 @@ struct UsageStoreCoverageTests { } @Test - func `status indicators and failure gate`() { + 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.enabledProvidersForDisplay())) + + #expect(store.snapshot(for: .claude) == nil) + #expect(store.errors[.claude] == nil) + #expect(store.statuses[.claude] == nil) + } + + @Test + func cleanupPreservesEnabledButUnavailableProviderState() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-preserve-unavailable") + 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: .synthetic, + metadata: #require(metadata[.synthetic]), + 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: .synthetic) + store._setErrorForTesting("stale", provider: .synthetic) + store.statuses[.synthetic] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + + store.clearDisabledProviderState(enabledProviders: Set(store.enabledProvidersForDisplay())) + + #expect(store.snapshot(for: .synthetic) != nil) + #expect(store.errors[.synthetic] == "stale") + #expect(store.statuses[.synthetic]?.indicator == .major) + } + + @Test + func statusIndicatorsAndFailureGate() { #expect(!ProviderStatusIndicator.none.hasIssue) #expect(ProviderStatusIndicator.maintenance.hasIssue) #expect(ProviderStatusIndicator.unknown.label == "Status unknown") diff --git a/docs/codex.md b/docs/codex.md index 0396d6e41..7c42a3298 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -32,7 +32,12 @@ 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. +- OpenAI web battery saver is a separate toggle. When enabled, routine background/settings-driven refreshes are reduced, but explicit manual refreshes still run. +- OpenAI web battery saver currently defaults to off. - 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 63f3aeaa0..e6f0516bc 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -41,7 +41,8 @@ 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. +- Battery saver toggle (currently off by default): reduces routine OpenAI web refreshes but still allows explicit manual refreshes. - 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..8636edb3f --- /dev/null +++ b/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md @@ -0,0 +1,67 @@ +--- +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. +- `SettingsStore` now defaults `openAIWebBatterySaverEnabled` to `false`; users can still opt into reduced routine OpenAI web refreshes separately. +- 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)