Skip to content

Reduce battery drain from OpenAI web extras#529

Open
cbrane wants to merge 6 commits intosteipete:mainfrom
cbrane:fix-battery-draining-issue
Open

Reduce battery drain from OpenAI web extras#529
cbrane wants to merge 6 commits intosteipete:mainfrom
cbrane:fix-battery-draining-issue

Conversation

@cbrane
Copy link

@cbrane cbrane commented Mar 14, 2026

Closes #139

Summary

This PR reduces the severe battery drain caused by CodexBar's optional OpenAI web extras.

The root issue was that CodexBar could keep loading and retrying the full chatgpt.com/codex/settings/usage single-page app in a hidden WKWebView. That is far too expensive for a menu bar app background feature, and in real usage it showed up in Activity Monitor as extreme energy impact.

This change keeps the normal Codex card working while making the expensive dashboard-only path safer, less likely to run unintentionally, and clearer in the UI.

Root Cause

The OpenAI web extras feature was implemented by loading https://chatgpt.com/codex/settings/usage in a hidden WKWebView and scraping the hydrated DOM.

That caused three problems:

  1. Failed dashboard refresh attempts were not throttled the same way successful attempts were, so stale cookies or login-required states could keep triggering repeated expensive work.
  2. Hidden WebViews could stay alive with chatgpt.com still loaded, which kept an offscreen ChatGPT tab around longer than necessary.
  3. The feature and its refresh behavior were coupled together, which made the product shape unclear once the battery-saving behavior was introduced.

We also found a separate background-refresh issue where scheduled work still walked broader provider lists than necessary.

What Changed

OpenAI web refresh and WebView lifecycle

  • Added a refresh gate so recent failed OpenAI dashboard attempts are throttled too, not only successful snapshots.
  • Evict cached OpenAI dashboard WebViews on login-required, account-mismatch, and reset/error paths.
  • Blank released cached WebViews to about:blank so a hidden chatgpt.com page is not left active between uses.
  • Reduced the hidden WebView cache lifetime from 10 minutes to 60 seconds.

Product/default behavior

  • OpenAI web extras is now off by default for new installs.
  • Existing users with explicit Codex cookie/web configuration are preserved so upgrades do not silently break them.
  • Added a nested Battery Saver option that only appears when OpenAI web extras are enabled.
  • Battery Saver defaults to on, so users who enable OpenAI web extras get the safer low-power refresh path by default.
  • When Battery Saver is on, the dashboard scrape stays on the reduced-refresh path from this PR: explicit/manual refreshes and submenu-driven stale checks.
  • When Battery Saver is off, OpenAI web extras can refresh normally again.
  • The settings copy now makes the battery/network tradeoff explicit.
  • OpenAI web submenus are hidden when the feature is disabled, even if stale dashboard data exists in memory.

Provider refresh scope

  • Background refresh/status work now only runs for enabled providers.
  • Token-cost refresh sequencing now only runs for enabled providers.
  • Disabled-provider state is actively cleared so stale provider data does not keep hanging around.

Manual refresh discoverability

  • Added a visible Refresh action back to the main menu so the explicit refresh path is actually reachable from the UI.
  • Routed the Codex Providers-pane refresh button through the full explicit refresh path when OpenAI web extras are enabled, so users can intentionally repopulate dashboard-only data.
  • Kept disabled-provider refreshes scoped to the provider-only path.

Documentation

  • Updated Codex/provider docs to describe OpenAI web extras as optional and off by default.
  • Added a troubleshooting/writeup document capturing the product rationale for the safer default.

Why This Product Shape

I think there are two separate product questions here:

  1. should OpenAI web extras exist at all?
  2. if they exist, should they refresh normally or in a battery-saving mode?

This PR now separates those concerns explicitly:

  • OpenAI Web Extras controls whether the optional dashboard-only data is available at all.
  • Battery Saver controls whether those extras stay on the safer reduced-refresh path or refresh normally in the background.

That means users still keep the optional feature, but they are protected by default when they turn it on.

What Users Keep Without OpenAI Web Extras

Users still keep the normal Codex data they check every day without the hidden ChatGPT dashboard:

  • session usage
  • weekly usage
  • reset timers
  • account email
  • plan label
  • normal credits remaining

What the optional web extras add is dashboard-only enrichment:

  • code review remaining
  • usage breakdown
  • credits history
  • dashboard purchase link / fallback enrichment

Measured Before/After

These were real Activity Monitor observations while validating this branch:

Before

  • CodexBar 12 hr Power: 305.16
  • CodexBar Energy Impact: 83.2
  • Hidden https://chatgpt.com child process Energy Impact: 4608.6

After

After running the fixed branch for more than 48 hours:

  • CodexBar 12 hr Power: 41.84
  • CodexBar Energy Impact: 0.0

That is roughly an 86.3% reduction versus the original 12 hr Power reading.

Validation

Passed

  • pnpm check
  • swift test --filter OpenAIWebRefreshGateTests --filter OpenAIDashboardWebViewCacheTests --filter OpenAIWebAccountSwitchTests --filter UsageStoreCoverageTests --filter SettingsStoreTests --filter ProviderSettingsDescriptorTests --filter StatusMenuTests
    • result: 86 tests in 7 suites passed
  • swift test --filter StatusMenuTests --filter ProvidersPaneCoverageTests
    • result: 26 tests in 2 suites passed
  • swift test --filter SettingsStoreTests --filter ProviderSettingsDescriptorTests --filter OpenAIWebRefreshGateTests --filter ProvidersPaneCoverageTests --filter StatusMenuTests
    • result: 77 tests in 5 suites passed
  • ./Scripts/compile_and_run.sh

Broad suite note

A broad swift test run in this environment still ends with unrelated failures outside the menu/OpenAI web refresh path touched here.

Files Of Interest

  • Sources/CodexBar/UsageStore.swift
  • Sources/CodexBar/UsageStore+OpenAIWeb.swift
  • Sources/CodexBar/UsageStore+BackgroundRefresh.swift
  • Sources/CodexBar/MenuDescriptor.swift
  • Sources/CodexBar/PreferencesProvidersPane.swift
  • Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift
  • Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift
  • Sources/CodexBar/SettingsStore.swift
  • Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift
  • Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift
  • Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift
  • Tests/CodexBarTests/UsageStoreCoverageTests.swift
  • Tests/CodexBarTests/StatusMenuTests.swift
  • Tests/CodexBarTests/ProvidersPaneCoverageTests.swift
  • docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md

Follow-Up

If OpenAI exposes a stable lightweight endpoint for the dashboard-only extras, the long-term fix would be to replace the hidden WebView scrape with direct HTTP requests using imported cookies, similar to the Claude web path.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5f909939f2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Activity Monitor, so only run it on explicit/manual refreshes and submenu-driven stale checks.
self.syncOpenAIWebState()
if forceTokenUsage {
await self.refreshOpenAIDashboardIfNeeded(force: true)

Choose a reason for hiding this comment

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

P2 Badge Let regular dashboard refreshes honor the new failure cooldown

This call now always passes force: true, and after this change there are no remaining call paths that invoke refreshOpenAIDashboardIfNeeded with force: false (the other call sites in this file also pass true). That means shouldSkipOpenAIWebRefresh/lastOpenAIDashboardAttemptAt never throttle real refresh traffic, so when the dashboard is stale and fetches are failing, repeated user-driven refreshes can still repeatedly load the hidden ChatGPT WebView without cooldown, which undercuts the battery-drain mitigation described in this PR.

Useful? React with 👍 / 👎.

@Astro-Han
Copy link
Contributor

Great work on this, @cbrane. The root cause analysis is thorough, and the product decision to default-off the WebView scraping path is the right call. The measured 86% reduction in 12-hr power is impressive.

I ran an independent 7.5-hour monitor (1,261 samples at 10s intervals) on v0.17.0 / macOS Tahoe / Apple Silicon that confirms the issue this PR addresses:

Metric Average Peak
CPU 1.9% 65.2%
Energy Impact 1.9 65.2
Memory 131 MB 184 MB
Samples with CPU > 10% 28 / 1,261 (2.2%)

The main spike hit at 03:18 and lasted ~2 minutes at 62–65% CPU, then dropped abruptly to 0%. Consistent with a WebView or CLI probe timing out.

Spike detail (03:17 – 03:21)
03:17:58, 0.0%, 121MB
03:18:21, 23.3%, 182MB  ← ramp up
03:18:33, 64.9%, 182MB  ← peak starts
03:19:20, 65.2%, 130MB  ← highest
03:20:06, 63.7%, 131MB  ← peak ends
03:20:27, 0.2%, 130MB   ← abrupt drop

Happy to re-run the same monitor after this PR lands to provide a before/after comparison.

// 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()
Copy link
Collaborator

Choose a reason for hiding this comment

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

If OpenAI web extras are enabled, what path repopulates the dashboard data after a normal refresh() from app launch, account changes, or relogin? I only see syncOpenAIWebState() here unless this is a forced refresh.

Copy link
Author

Choose a reason for hiding this comment

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

Intentional: a normal background refresh() no longer repopulates the OpenAI dashboard data on app launch/account-change/relogin by itself. syncOpenAIWebState() only keeps the OpenAI-web state aligned with the current Codex identity (for example, clearing stale dashboard data and marking account changes) so we do not silently spin up the hidden ChatGPT WebView on the timer/startup path.

The repopulation paths are all explicit/foreground now:

  • manual Refresh: refreshNow() -> refresh(forceTokenUsage: true) -> refreshOpenAIDashboardIfNeeded(force: true)
  • manual cookie import: importOpenAIDashboardBrowserCookiesNow() -> refreshOpenAIDashboardIfNeeded(force: true)
  • dashboard submenu open when stale: menuWillOpen -> requestOpenAIDashboardRefreshIfStale(reason: "submenu open") -> refreshOpenAIDashboardIfNeeded(force: true)

So after app launch, account change, or relogin, we intentionally wait until the user explicitly refreshes or opens a dashboard-backed submenu before repopulating the dashboard snapshot. That tradeoff is what removes the hidden background ChatGPT tab from the steady-state refresh path. Happy to expand the inline comment if that would make the intent clearer in the code too.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thanks, that clarifies the intended tradeoff. I still may be missing the intended entry point, though: I can see refreshNow() in code, but I’m not finding a visible UI action that calls it. In manual testing, the Providers refresh button only hits refreshProvider(...), so the explicit repopulation path doesn’t seem discoverable from the current UI.

Copy link
Author

Choose a reason for hiding this comment

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

Good catch, you were right that the explicit refresh path was not discoverable enough from the current UI.

I pushed a small follow-up in b6f4adc to close that gap:

  • added a visible Refresh action back to the main menu, which calls the existing explicit refresh path
  • routed the Codex Providers-pane refresh button through the full explicit refresh path when OpenAI web extras are enabled
  • kept disabled-provider refreshes on the provider-only path

That keeps the battery fix intact by avoiding timer-driven background dashboard scraping, but still gives users an obvious manual way to repopulate the dashboard-only OpenAI web data.

I also added coverage for both pieces:

  • StatusMenuTests checks that Refresh is actually present in the menu
  • ProvidersPaneCoverageTests checks that Codex uses the full explicit refresh path when OpenAI web extras are enabled

Validation:

  • pnpm check
  • swift test --filter StatusMenuTests --filter ProvidersPaneCoverageTests
  • ./Scripts/compile_and_run.sh

@cbrane
Copy link
Author

cbrane commented Mar 14, 2026

@Astro-Han - thank you for running a monitor to help confirm that it addresses the root cause! Took a while and happy to know that you saw results as well.

@cbrane
Copy link
Author

cbrane commented Mar 15, 2026

@ratulsarna in addition to the small follow-up above, I also took care of all the merge conflicts as well. Let me know if this is merge ready, and if not, let me know anything else that I need to adjust! I can get that taken care of.

@cbrane cbrane requested a review from ratulsarna March 16, 2026 03:17
…issue

# Conflicts:
#	Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift
#	Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
#	Tests/CodexBarTests/SettingsStoreTests.swift
#	Tests/CodexBarTests/StatusMenuTests.swift
#	Tests/CodexBarTests/UsageStoreCoverageTests.swift
@cbrane
Copy link
Author

cbrane commented Mar 16, 2026

@ratulsarna there were some issues with CI. I went ahead and fixed those and for now it is all good!

…issue

# Conflicts:
#	Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift
@cbrane
Copy link
Author

cbrane commented Mar 18, 2026

@ratulsarna I went ahead and fixed the CI again with another set of conflicts from more commits that were added! If you could re-review and merge ASAP since it fixes a major issue that would be great, or let know if there's anything I need to do. Thanks :)

@ratulsarna
Copy link
Collaborator

First, thanks a lot for this PR overall and following through with it. I've spent quite a bit of time going through this.
I think the battery-fix direction here makes sense, but I’m not fully convinced by the current product shape.

To me this feels like two separate questions:

  1. should OpenAI Web Extras exist at all?
  2. if they do, should they refresh normally or in a battery-saving mode?

That’s why I’m wondering if this would make more sense as:

  • OpenAI Web Extras which is off by default and
  • a Battery Saver option that only appears when OpenAI Web Extras are enabled and when enabled , does what this PR is trying to do.

My hesitation with the current approach is that it makes “enabled” feel a bit like “enabled, but often stale unless manually refreshed,” which feels like a UX regression. I’d be more comfortable if we separated feature availability from battery-saving behaviour.

let openAIWebAccessEnabled = openAIWebAccessDefault ?? false
if openAIWebAccessDefault == nil { userDefaults.set(false, forKey: "openAIWebAccessEnabled") }
let openAIWebBatterySaverDefault = userDefaults.object(forKey: "openAIWebBatterySaverEnabled") as? Bool
let openAIWebBatterySaverEnabled = openAIWebBatterySaverDefault ?? true
Copy link
Collaborator

Choose a reason for hiding this comment

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

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

self.settings.codexCookieSource.isEnabled,
batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled,
force: forceTokenUsage)
if Self.shouldRunOpenAIWebRefresh(refreshPolicy) {
Copy link
Collaborator

@ratulsarna ratulsarna Mar 22, 2026

Choose a reason for hiding this comment

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

In Battery Saver mode, do we still end up going through a forced dashboard refresh from the stale-submenu path elsewhere? I’m wondering if it’s worth tracing whether the reduced-refresh path still fully benefits from the cooldown after a failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

consuming too much power on my macbook

3 participants