diff --git a/.agents/skills/qa-test/SKILL.md b/.agents/skills/qa-test/SKILL.md new file mode 100644 index 00000000..81c752a1 --- /dev/null +++ b/.agents/skills/qa-test/SKILL.md @@ -0,0 +1,118 @@ +--- +name: qa-test +description: "CodexBar live QA/e2e testing: run provider usage matrix checks, validate real app config, use Peekaboo for menu proof, use Browser Use/official docs for API spec or logged-in dashboard checks, and handle 1Password credentials safely." +--- + +# CodexBar Live QA + +Use for live provider testing, release smoke tests, menu verification, or debugging “provider works/fails” reports. + +## Rules + +- Work from the CodexBar repo checkout. +- Use the packaged CLI first: `CodexBar.app/Contents/Helpers/CodexBarCLI`. +- Do not use `CodexBar.app/Contents/MacOS/codexbar`; that is the app binary and may appear to hang as a CLI. +- Never run broad `env`, `set`, or secret regex dumps. +- Use `$one-password` for secrets: all `op` commands inside one persistent tmux session, service account first, no raw secret output. +- Treat browser-cookie/keychain flows as prompt-risky. Prefer CLI/API-token checks and `KeychainNoUIQuery`-safe tests unless the user explicitly requested live UI. +- For current API behavior, browse official provider docs only. + +## CLI Matrix + +Run the bundled script: + +```bash +.agents/skills/qa-test/scripts/live_provider_matrix.sh --enabled +``` + +Useful modes: + +```bash +.agents/skills/qa-test/scripts/live_provider_matrix.sh --provider all +.agents/skills/qa-test/scripts/live_provider_matrix.sh --providers openai,zai,deepseek +.agents/skills/qa-test/scripts/live_provider_matrix.sh --default +``` + +Interpretation: + +- `--enabled` asks `CodexBarCLI config providers` for enabled providers, honoring `CODEXBAR_CONFIG` and default toggles. +- `--default` runs the app-facing default command with no provider override. +- `--provider all` forces every registered provider and is expected to fail for providers without sessions/keys. +- A green app config needs `--enabled` and `--default` clean; `--provider all` is a discovery/triage tool. + +## Config QA + +Validate config: + +```bash +CodexBar.app/Contents/Helpers/CodexBarCLI config validate +stat -f '%Lp %N' "$HOME/.codexbar/config.json" +``` + +Redact config shape: + +```bash +jq '(.providers // []) |= map(.apiKey = (if .apiKey then "" else .apiKey end) | + .secretKey = (if .secretKey then "" else .secretKey end) | + .cookieHeader = (if .cookieHeader then "" else .cookieHeader end) | + (if .id == "stepfun" and has("region") then .region = "" else . end) | + .tokenAccounts = (if .tokenAccounts then (.tokenAccounts | .accounts = (.accounts | map(.token = ""))) else .tokenAccounts end))' \ + "$HOME/.codexbar/config.json" +``` + +Before editing config, make a backup: + +```bash +cp "$HOME/.codexbar/config.json" "$HOME/.codexbar/config.pre-qa-$(date +%Y%m%d%H%M%S).json" +chmod 600 "$HOME/.codexbar"/config.pre-qa-*.json +``` + +## Live Menu QA + +Use Peekaboo after CLI checks: + +```bash +pkill -x CodexBar || pkill -f 'CodexBar.app/Contents/MacOS/CodexBar' || true +open -n "$PWD/CodexBar.app" +peekaboo menu list-all --json | rg -i 'codexbar' +peekaboo menu click-extra --title codexbar-merged --json +screencapture -x /tmp/codexbar-live-menu.png +``` + +Crop top-right menu if needed: + +```bash +sips --cropToHeightWidth 900 340 --cropOffset 20 2650 /tmp/codexbar-live-menu.png \ + --out /tmp/codexbar-live-menu-crop.png >/dev/null +``` + +Verify visually with `view_image`. Confirm provider tabs/rows match enabled config and no failing provider dominates the first screen. + +## Browser Use + +Use `$browser-use` only when a logged-in dashboard, API key page, or provider docs need browser/profile state. + +Existing Chrome path: + +```bash +mcporter call chrome-devtools.list_pages --args '{}' --output text +mcporter call chrome-devtools.navigate_page --args '{"url":"https://provider.example"}' --output text +mcporter call chrome-devtools.take_snapshot --args '{}' --output text +``` + +If Browser Use is unavailable, say so and use web search for public official docs; do not substitute isolated Playwright for login/profile-dependent pages. + +## Fix Triage + +- Missing auth/session: configure key/session if available; otherwise leave provider disabled or report blocked auth. +- Wrong provider API/spec: inspect official docs, then patch fetcher/settings/tests. +- Provider key exists but live API rejects it: keep key stored if useful, disable provider if the menu would show a persistent error. +- User-facing behavior changes need `CHANGELOG.md`. +- Code fixes need focused tests, `make check`, `$autoreview`, and live CLI proof before landing. + +## Known CodexBar QA Notes + +- OpenAI Admin API key is the useful usage provider key. Project `OPENAI_API_KEY` values can fail legacy credit-balance fallback with 403. +- Deepgram usage requires a key/project with Management API permissions; transcription-only keys can return 403. +- Groq usage uses the Prometheus metrics API, not ordinary inference endpoints. +- MiniMax pay-as-you-go API keys and Token Plan/Coding Plan keys are different; wrong key kind can leave usage unavailable. diff --git a/.agents/skills/qa-test/agents/openai.yaml b/.agents/skills/qa-test/agents/openai.yaml new file mode 100644 index 00000000..3bf7a7b2 --- /dev/null +++ b/.agents/skills/qa-test/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CodexBar QA Test" + short_description: "Run live CodexBar CLI and menu QA safely." + default_prompt: "Run CodexBar live QA with CLI, Peekaboo, browser docs, and 1Password-safe credential checks." diff --git a/.agents/skills/qa-test/references/api-specs.md b/.agents/skills/qa-test/references/api-specs.md new file mode 100644 index 00000000..8bb7298c --- /dev/null +++ b/.agents/skills/qa-test/references/api-specs.md @@ -0,0 +1,10 @@ +# API Spec Pointers + +Use current official docs for provider API behavior. Prefer these searches/pages before patching fetchers: + +- MiniMax: `https://platform.minimax.io/docs/llms.txt`; key types differ between pay-as-you-go API keys and Token Plan/Coding Plan keys. +- Deepgram: `https://developers.deepgram.com/llms.txt`; usage/project APIs require Management permissions and project-scoped keys. +- Groq: `https://console.groq.com/docs/prometheus-metrics`; usage metrics use `https://api.groq.com/v1/metrics/prometheus`. +- LLM Proxy/LiteLLM: `https://docs.litellm.ai/`; CodexBar expects an LLM-API-Key-Proxy compatible `/v1/quota-stats` endpoint plus base URL. + +When citing docs in a user-facing answer, browse the current page and include source links. diff --git a/.agents/skills/qa-test/scripts/live_provider_matrix.sh b/.agents/skills/qa-test/scripts/live_provider_matrix.sh new file mode 100755 index 00000000..0c8240ce --- /dev/null +++ b/.agents/skills/qa-test/scripts/live_provider_matrix.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +CLI="${CODEXBAR_CLI:-$ROOT/CodexBar.app/Contents/Helpers/CodexBarCLI}" +TIMEOUT_BIN="${TIMEOUT_BIN:-$(command -v gtimeout || command -v timeout || true)}" +WEB_TIMEOUT="${CODEXBAR_QA_WEB_TIMEOUT:-12}" +CASE_TIMEOUT="${CODEXBAR_QA_CASE_TIMEOUT:-60}" + +usage() { + cat <<'USAGE' +Usage: + live_provider_matrix.sh --enabled + live_provider_matrix.sh --default + live_provider_matrix.sh --provider all + live_provider_matrix.sh --providers openai,zai,deepseek + +Environment: + CODEXBAR_CLI=/path/to/CodexBarCLI + CODEXBAR_CONFIG=/path/to/config.json + CODEXBAR_QA_WEB_TIMEOUT=12 + CODEXBAR_QA_CASE_TIMEOUT=60 +USAGE +} + +if [[ ! -x "$CLI" ]]; then + echo "missing CodexBarCLI at $CLI" >&2 + exit 2 +fi +if [[ -z "$TIMEOUT_BIN" ]]; then + echo "missing timeout command (install coreutils for gtimeout)" >&2 + exit 2 +fi +if ! command -v node >/dev/null 2>&1; then + echo "missing node" >&2 + exit 2 +fi + +mode="${1:-}" +shift || true + +providers=() +case "$mode" in + --enabled) + provider_status="$(mktemp)" + provider_err="$(mktemp)" + provider_list="$(mktemp)" + if ! "$CLI" config providers --format json --json-only >"$provider_status" 2>"$provider_err"; then + rm -f "$provider_status" "$provider_err" "$provider_list" + echo "failed to list providers via CodexBarCLI config providers" >&2 + exit 2 + fi + if ! node - "$provider_status" >"$provider_list" <<'NODE'; then +const fs = require("fs"); +const path = process.argv[2]; +const raw = fs.readFileSync(path, "utf8").trim(); +const payload = JSON.parse(raw); +if (!Array.isArray(payload)) { + throw new Error("config providers output is not an array"); +} +for (const item of payload) { + if (item && item.enabled === true && typeof item.provider === "string" && item.provider) { + console.log(item.provider); + } +} +NODE + rm -f "$provider_status" "$provider_err" "$provider_list" + echo "failed to parse CodexBarCLI config providers output" >&2 + exit 2 + fi + while IFS= read -r provider; do + [[ -n "$provider" ]] && providers+=("$provider") + done <"$provider_list" + rm -f "$provider_status" "$provider_err" "$provider_list" + if [[ "${#providers[@]}" -eq 0 ]]; then + echo "no enabled providers found via CodexBarCLI config providers" >&2 + exit 2 + fi + ;; + --default) + providers=("__default__") + ;; + --provider) + if [[ -z "${1:-}" ]]; then + echo "missing provider" >&2 + exit 2 + fi + providers=("${1:-}") + ;; + --providers) + if [[ -z "${1:-}" ]]; then + echo "missing providers" >&2 + exit 2 + fi + IFS=',' read -r -a providers <<< "${1:-}" + ;; + -h|--help|"") + usage + exit 0 + ;; + *) + echo "unknown mode: $mode" >&2 + usage >&2 + exit 2 + ;; +esac + +redact_node=' +const redact = s => String(s || "") + .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+/g, "") + .replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-REDACTED") + .replace(/gsk_[A-Za-z0-9_-]{12,}/g, "gsk_REDACTED") + .replace(/[A-Za-z0-9_-]{32,}/g, m => /[A-Za-z]/.test(m) && /[0-9]/.test(m) ? "" : m); +' + +run_one() { + local name="$1" + shift + local out err start end elapsed st node_status + out="$(mktemp)" + err="$(mktemp)" + start="$(date +%s)" + "$TIMEOUT_BIN" "$CASE_TIMEOUT" "$CLI" usage "$@" --format json --json-only --web-timeout "$WEB_TIMEOUT" >"$out" 2>"$err" + st=$? + end="$(date +%s)" + elapsed=$((end - start)) + node - "$name" "$st" "$elapsed" "$out" "$err" <&2 + exit 2 +fi +exit "$overall" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 055945f4..4a96f684 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,12 @@ name: CI on: + # CI runs on PRs (the merge gate) and on pushes to the long-lived branches + # only — NOT on every feature-branch commit. A feature branch gets its CI + # through the PR it opens (pull_request: opened/synchronize), so intermediate + # work-in-progress commits no longer each spawn a (often red) CI run. push: - # `["*"]` only matches single-level branch names; anything with a slash - # (e.g. `feature/…`, `release/…`) is silently skipped. `["**"]` matches - # arbitrary-depth branches so every push triggers CI. - branches: ["**"] + branches: [mobile-dev, main] pull_request: concurrency: @@ -23,6 +24,14 @@ jobs: timeout-minutes: 70 steps: - uses: actions/checkout@v6 + # Full history so `lint.sh audit_parser_version` can compute the + # `origin/mobile-dev...HEAD` merge-base. A shallow (depth-1) checkout + # makes the parser-version audit false-fail on any PR that touches the + # cost-usage parser (it can't see the parserLogicVersion bump in the + # diff). See Scripts/lint.sh: "In CI, ensure your checkout fetches + # origin/mobile-dev (e.g. fetch-depth: 0)." + with: + fetch-depth: 0 - name: Select Xcode 26.1.1 (if present) or fallback to default run: | diff --git a/.mac-release.env b/.mac-release.env index 07a027ed..e734043a 100644 --- a/.mac-release.env +++ b/.mac-release.env @@ -15,7 +15,7 @@ MAC_RELEASE_ARTIFACT_PREFIX='CodexBar-macos-[A-Za-z0-9_+-]+-' MAC_RELEASE_FEED_URL='https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml' MAC_RELEASE_DOWNLOAD_URL_PREFIX='https://github.com/steipete/CodexBar/releases/download/v${MARKETING_VERSION}/' -MAC_RELEASE_PRECHECK='swiftformat Sources Tests >/dev/null && swiftlint --strict && swift test --parallel' +MAC_RELEASE_PRECHECK='swiftformat Sources Tests >/dev/null && swiftlint --strict && swift test --enable-xctest --disable-swift-testing && swift test --enable-swift-testing --disable-xctest --no-parallel' MAC_RELEASE_PACKAGE_CMD='Scripts/sign-and-notarize.sh' MAC_RELEASE_TAG_SIGNED=1 MAC_RELEASE_TAG_FORCE=1 diff --git a/AGENTS.md b/AGENTS.md index dd08f4cc..50a5c985 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ Full status definitions and index are in `CodexBarMobile/Research/README.md`. - Build with `xcodebuild` to verify compilation - Run unit tests if applicable - Verify on simulator or real device as needed +- Never run tests/checks or ad-hoc validation that can display macOS Keychain prompts. Live provider probes, browser-cookie imports, `codexbar usage` against real accounts, and real SecItem reads must be explicitly requested; otherwise use parser tests, stubs, test stores, or `KeychainNoUIQuery`. ## Step 5 — Documentation diff --git a/CHANGELOG.md b/CHANGELOG.md index dc7f09cb..7214bf54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 0.32.4.1 (Mobile 1.11.0 · build 79.1) — 2026-06-03 — upstream v0.32.4 sync + +Syncs the Mac app to upstream CodexBar **v0.32.4** (spanning 0.32.0–0.32.4) and ships the paired iOS **1.11.0** companion. A refinement + reliability batch — no new providers; the visible wins are quieter, more accurate provider data that flows through to iPhone automatically. + +### Fixed / Improved + +- **Antigravity** quota rows are cleaner — image / lite / autocomplete / internal noise rows no longer skew the summary bar (#1209). +- **Copilot** zero-entitlement business tokens no longer show a misleading usage percentage (#1258). +- **Augment** usage parses correctly again after the upstream `auggie` status-format change, with a browser-cookie fallback (#1224). +- **Claude** keeps the last good web-usage snapshot through a brief Unauthorized refresh instead of blanking, and delegates the CLI OAuth refresh token so CodexBar stops forcing re-logins (#1220, #1239). +- **Codex cost** scanner rewrite (faster scans, new fast-JSON path) — the on-disk cost cache is invalidated and re-scanned so Codex and Claude cost cards reflect the new parser. +- Plus upstream menu-bar, OpenAI Web, and notarization-path hardening for macOS 26. +- **iOS** — new provider search at the top of the Usage list (filter by name) for easier navigation of a long synced provider list. + +### Compatibility + +- No wire-format, schema, or CloudKit change. Mixing app versions across Macs and iPhones stays safe — the refinements arrive once Mac is on 0.32.4. + +### 中文说明 + +同步 Mac 端到上游 CodexBar **v0.32.4**(覆盖 0.32.0–0.32.4),并配套发布 iOS **1.11.0**。本批以精修 + 可靠性为主,无新增 provider;可见收益是更干净、更准确的 provider 数据,并自动同步到 iPhone。 + +### 修复 / 改进 + +- **Antigravity** 配额行更干净 —— image / lite / autocomplete / internal 噪声行不再干扰汇总进度条(#1209)。 +- **Copilot** zero-entitlement 商业 token 不再显示误导性用量百分比(#1258)。 +- **Augment** 在上游 `auggie` 状态格式变更后用量重新正确解析,并增加浏览器 cookie fallback(#1224)。 +- **Claude** 短暂 Unauthorized 刷新期间保留最后有效的 web 用量快照而不清空,并把 CLI 的 OAuth refresh token 委托出去,避免强制重登(#1220、#1239)。 +- **Codex 成本** 扫描器重写(更快、新增 fast-JSON 路径)—— 失效并重扫磁盘成本缓存,使 Codex 与 Claude 成本卡反映新 parser。 +- 以及上游菜单栏、OpenAI Web、公证路径加固(macOS 26)。 +- **iOS** —— Usage 列表顶部新增 provider 搜索(按名称过滤),同步的 provider 多时更好找。 + +### 兼容性 + +- 无 wire / schema / CloudKit 变更。Mac 与 iPhone 间混用版本安全 —— 待 Mac 升级到 0.32.4 后这些精修即到达。 + +--- + ## 0.31.0.2 (Mobile 1.10.0 · build 73.2) — 2026-06-02 — cost-cache invalidation hotfix Hotfix on top of 0.31.0.1: forces the Codex and Claude cost-usage caches to re-scan after the v0.31.0 parser update, so cost cards show the new parser's numbers instead of stale cached attributions. @@ -96,6 +134,55 @@ Syncs the Mac app to upstream CodexBar v0.29.0 and ships the paired iOS 1.9.0 co - 本 Mac 版本:0.29.0.1(fork build 68.1)。两边都更新才能用全套功能。 --- +## 0.32.4 — 2026-06-02 + +### Fixed +- Menu bar: avoid queuing redundant provider refreshes when opening a fresh merged-menu dropdown, while still retrying missing or stale provider data after menu tracking ends (#1235, #1277). Thanks @hhh2210! + +## 0.32.3 — 2026-06-02 + +### Fixed +- Menu bar: stop forcing a private preferred-position value for fresh status items; suspicious stored positions are now cleared so AppKit can place CodexBar normally on macOS 26 / 5K displays (#1267). Thanks @AdrianSimionov, @kirocop, and @Yuxin-Qiao! +- Menu bar: cache provider brand icons so merged-icon status updates no longer repeatedly parse SVG assets on the main thread during hover/open animations (#1235, #1274). Thanks @andradebruno, @xingpz2008, and @Yuxin-Qiao! +- Copilot: treat GitHub Copilot Business token-billing zero-entitlement quotas as unavailable instead of showing misleading 0% used usage (#1258, #1270). Thanks @devYRPauli! +- Menu bar: prepare closed menus after refresh and only reuse stale dropdown content for data-refresh invalidations so merged menu opens stay responsive without bypassing privacy or structure changes (#1261). Thanks @ProspectOre! +- OpenAI Web: stop reloading away from login and Cloudflare blocking states so the dashboard WebView does not loop on route corrections (#1259). Thanks @ProspectOre! + +## 0.32.2 — 2026-06-01 + +### Added +- QA: document the live CodexBar e2e flow and add a redacted provider-matrix helper for packaged CLI smoke tests. + +### Fixed +- Menu bar: add breathing room to compact Codex account rows so the provider, account, status, and plan labels no longer hug the row edges. +- Performance: make Codex token-cost scanning faster and more memory-efficient on large local session corpora. + +## 0.32.1 — 2026-05-31 + +### Fixed +- Claude: keep Claude CLI-owned OAuth refresh tokens delegated to Claude Code when CLI storage is present, preventing CodexBar from consuming rotating refresh tokens and forcing re-login (#1161, #1239). Thanks @RajvardhanPatil07! +- Menu bar: reuse short-lived Codex account reconciliation snapshots so repeated menu rebuilds do not reread local auth state on every open. +- Menu bar: defer automatic provider refreshes until after AppKit menu tracking ends so opening the dropdown no longer starts work that can freeze focus and keyboard input. +- Menu bar: suppress background keychain and OpenAI dashboard work during startup/menu tracking so the dropdown stays clickable without macOS keychain prompts or WebKit memory spikes. + +## 0.32.0 — 2026-05-31 + +### Added +- Settings: add search to the Providers pane so large provider lists can be filtered by name or id (#1184). Thanks @046081-dotcom! + +### Fixed +- Augment: parse the updated `auggie account status` output format, fall back to browser cookies when CLI parsing fails, and restore session cookie detection (#1224). Thanks @bcharleson! +- Amp/Ollama: require HTTPS before reattaching imported browser cookies on provider redirects to avoid cleartext cookie exposure (#1226). Thanks @Hinotoi-agent! +- Antigravity: filter noisy remote OAuth per-model quota rows, keep consumed noisy rows detail-only, and prevent image/lite/autocomplete/internal rows from driving summary bars (#1209). Thanks @guhyun9454! +- Claude: preserve the last good Claude Web usage snapshot across transient Unauthorized refresh failures while still surfacing repeated auth failures (#1220). Thanks @LeoLin990405! +- CLI: avoid executing a same-user mutable temporary installer script across the macOS administrator privilege boundary (#1222). Thanks @Hinotoi-agent! +- Codex: cancel OpenAI WebKit dashboard refreshes promptly and avoid an immediate second background WebView retry after timeouts, reducing launch-time Web Content CPU spikes (#1217). +- Menu: refresh open Codex menu adjuncts as dashboard, credits, token-cost, and plan-history data become ready after cold start (#1150). Thanks @AmrMohamad! +- Menu bar: defer background parent-menu rebuilds until AppKit menu tracking ends so late-arriving usage data cannot stall dropdown hover on macOS 26.5 (#1227). +- Menu bar: give CodexBar status items stable placement identities while preserving existing upgrade placement state (#1216). Thanks @pdurlej! +- Release: isolate notarization API keys and upload ZIPs in a private per-run temporary directory instead of predictable shared /tmp paths (#1228). Thanks @Hinotoi-agent! +- Status: retry startup refreshes a few times after transient offline/network failures so provider status can recover after macOS brings the network online (#1211). + ## 0.31.0 — 2026-05-28 ### Changed @@ -110,6 +197,9 @@ Syncs the Mac app to upstream CodexBar v0.29.0 and ships the paired iOS 1.9.0 co - Localization: add Swedish as a selectable app language (#1186). Thanks @yeager! ### Fixed +- CLI: bound `codexbar serve` requests with a configurable timeout and coalesce concurrent cache misses so hung `/usage` callers no longer stampede provider refreshes (#1208). Thanks @enieuwy! +- Claude: add Opus 4.8 to the built-in pricing fallback so stale models.dev caches still show token cost (#1214, fixes #1210). Thanks @devYRPauli! +- Codex: preserve authorized web dashboard credits-only snapshots instead of treating missing usage windows as a failed refresh (#1206, fixes #1204). Thanks @soumikbhatta! - Cost history: make token-cost JSONL scans cancellation-aware so quitting, forced refreshes, and account switches can stop stale scans sooner. - Codex: show Spark 5-hour and weekly usage as separate quota lanes in Codex breakdowns (#1201). - Codex: show captured `codex login` output when managed Add Account fails so users can recover from account-selection or OAuth failures (#1199). Thanks @chapati23! diff --git a/CodexBarMobile/CHANGELOG.md b/CodexBarMobile/CHANGELOG.md index b1f5020d..d8423082 100644 --- a/CodexBarMobile/CHANGELOG.md +++ b/CodexBarMobile/CHANGELOG.md @@ -2,6 +2,41 @@ All notable changes to the CodexBar iOS companion app will be documented in this file. +## [1.11.0 (149)] — 2026-06-04 — Usage provider search + +### Added + +- **Provider search on the Usage tab** — a search bar pinned at the top of the Usage + list filters provider cards by name / ID. Helps when many providers are synced (20+) + and scrolling to find one is tedious; shows a "no matching providers" state on no hits. + 4-language localized. + +--- + +## [1.11.0 (148)] — 2026-06-03 — v0.32.4 upstream sync (refinements, no new iOS code) + +### Changed + +- Paired with Mac **0.32.4.1 / build 79.1** (upstream v0.32.0–v0.32.4 sync). No + functional iOS code change — the improvements reach iPhone through the existing + Mac → iCloud sync. Added the in-app 1.11.0 "What's New" entry (4 languages). + +### Improved (Mac-side, delivered to iOS via synced data) + +- **Antigravity** quota rows are cleaner (image / lite / autocomplete / internal noise + rows filtered, #1209). +- **Copilot** zero-entitlement usage % is no longer misleading (#1258). +- **Augment** parsing fixed for the new upstream `auggie` status format (#1224). +- **Claude** keeps the last good usage snapshot through brief auth hiccups (#1220). +- **Codex / Claude cost** re-scanned by the v0.32 cost-scanner update (cache invalidated + via parserLogicVersion 4→5 + parser-hash regen). + +### Notes + +- No wire-format / schema change; older iOS and older Macs interoperate safely. + +--- + ## [1.10.0 (147)] — 2026-06-03 — In-app 1.10.0 release notes ### Fixed diff --git a/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj b/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj index 038ad9fd..14f41497 100644 --- a/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj +++ b/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj @@ -1039,7 +1039,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_IDENTITY = ""; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 149; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -1050,7 +1050,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.10.0; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = com.o1xhack.codexbar.sync; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -1218,7 +1218,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = CodexBarMobilePushExtension/PushExtension.entitlements; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 149; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobilePushExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1226,7 +1226,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.10.0; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = com.o1xhack.codexbar.mobile.pushextension; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -1240,14 +1240,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CodexBarMobile/CodexBarMobile.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 149; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobile/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.10.0; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = com.o1xhack.codexbar.mobile; SDKROOT = iphoneos; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1278,14 +1278,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CodexBarMobile/CodexBarMobile.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 149; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobile/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.10.0; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = com.o1xhack.codexbar.mobile; SDKROOT = iphoneos; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1314,7 +1314,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_IDENTITY = ""; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 149; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -1325,7 +1325,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.10.0; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = com.o1xhack.codexbar.sync; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -1338,7 +1338,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = CodexBarMobilePushExtension/PushExtension.entitlements; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 149; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobilePushExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1346,7 +1346,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.10.0; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = com.o1xhack.codexbar.mobile.pushextension; SDKROOT = iphoneos; SKIP_INSTALL = YES; diff --git a/CodexBarMobile/CodexBarMobile/ContentView.swift b/CodexBarMobile/CodexBarMobile/ContentView.swift index b32669f3..a9e0d31a 100644 --- a/CodexBarMobile/CodexBarMobile/ContentView.swift +++ b/CodexBarMobile/CodexBarMobile/ContentView.swift @@ -174,6 +174,9 @@ private struct ProviderListView: View { /// Long-term persistence isn't needed since the candidate goes away /// the moment the legacy Mac upgrades (Research/019 §9 logic). @State private var dismissedCandidateKeys = Set() + /// Filters the Usage provider list by name / ID. Helps when many + /// providers are synced (20+) and scrolling to find one is tedious. + @State private var searchText = "" var body: some View { // Drop extinct mock zombies before any rendering so duplicate @@ -215,10 +218,15 @@ private struct ProviderListView: View { // upstream of this grouping, so each group's accounts are all // distinct (no duplicates within). let groups = liveProviders.groupedByProvider() + let query = self.searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let filteredGroups = query.isEmpty ? groups : groups.filter { group in + group.representative.providerName.localizedCaseInsensitiveContains(query) + || group.providerID.localizedCaseInsensitiveContains(query) + } return ScrollView { LazyVStack(spacing: 16) { MockProviderBanner(snapshot: self.snapshot) - ForEach(groups) { group in + ForEach(filteredGroups) { group in // Within-group linkage candidate: surface on the // group row if ANY account in the group has one // (typically the legacy/missing-identity card). @@ -267,6 +275,14 @@ private struct ProviderListView: View { .accessibilityIdentifier("provider-group-\(group.providerID)") } + if filteredGroups.isEmpty { + EmptyStateView( + title: "No matching providers", + message: "No provider matches your search. Try a different name.", + systemImage: "magnifyingglass") + .padding(.vertical, 32) + } + // Sync status at scroll bottom if self.isDemoMode { Label("Showing demo data", systemImage: "sparkles") @@ -286,6 +302,10 @@ private struct ProviderListView: View { await self.usageData.refresh() } .modifier(SoftScrollEdgeModifier()) + .searchable( + text: self.$searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: Text("Search providers")) } } @@ -2415,8 +2435,29 @@ private struct ReleaseNotesVersion: Identifiable { private enum MobileReleaseNotesCatalog { static let versions: [ReleaseNotesVersion] = [ ReleaseNotesVersion( - version: "1.10.0", + version: "1.11.0", status: String(localized: "Latest"), + summary: String(localized: "Quieter, more accurate provider data synced from your Mac — Antigravity quota rows without the noise, correct Copilot usage on zero-entitlement plans, fixed Augment parsing, and steadier Claude readings — from the CodexBar 0.32.4 sync."), + sections: [ + .init( + title: String(localized: "What's New"), + items: [ + String(localized: "Search — filter the Usage list by provider name; handy when many providers are synced."), + String(localized: "Antigravity — quota rows are cleaner: image / lite / autocomplete / internal noise rows no longer skew the summary bar."), + String(localized: "Copilot — zero-entitlement business tokens no longer show a misleading usage percentage."), + String(localized: "Augment — usage parses correctly again after the upstream status-format change."), + String(localized: "Claude — a brief sign-in hiccup no longer blanks your usage; the last good reading is kept."), + String(localized: "Codex / Claude cost — refreshed by the v0.32 cost-scanner update; your cost cards re-scan to the corrected numbers."), + ]), + .init( + title: String(localized: "Required Mac version"), + items: [ + String(localized: "Update Mac CodexBar to 0.32.4 (fork build 79.1 or later). iPhone 1.11.0 stays forward-compatible with older Mac builds — these refinements simply arrive once Mac is updated."), + ]), + ]), + ReleaseNotesVersion( + version: "1.10.0", + status: "", summary: String(localized: "DeepSeek web-session usage and cost on your iPhone, Codex Spark and Antigravity per-model quota lanes synced through, and cost cards that show request counts in the right currency — from the CodexBar 0.31.0 sync."), sections: [ .init( diff --git a/CodexBarMobile/CodexBarMobile/Localizable.xcstrings b/CodexBarMobile/CodexBarMobile/Localizable.xcstrings index f895ed45..c3093f6d 100644 --- a/CodexBarMobile/CodexBarMobile/Localizable.xcstrings +++ b/CodexBarMobile/CodexBarMobile/Localizable.xcstrings @@ -14419,6 +14419,314 @@ } } } + }, + "Quieter, more accurate provider data synced from your Mac — Antigravity quota rows without the noise, correct Copilot usage on zero-entitlement plans, fixed Augment parsing, and steadier Claude readings — from the CodexBar 0.32.4 sync.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quieter, more accurate provider data synced from your Mac — Antigravity quota rows without the noise, correct Copilot usage on zero-entitlement plans, fixed Augment parsing, and steadier Claude readings — from the CodexBar 0.32.4 sync." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac から同期される provider データがより静かで正確に — Antigravity のクォータ行からノイズを除去、zero-entitlement プランでの Copilot 使用率を修正、Augment の解析を修正、Claude の表示も安定。CodexBar 0.32.4 の同期による更新です。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从 Mac 同步来的 provider 数据更干净、更准确 —— Antigravity 配额行去除噪声、修正 zero-entitlement 套餐的 Copilot 用量、修复 Augment 解析、Claude 读数更稳。来自 CodexBar 0.32.4 同步。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "從 Mac 同步來的 provider 資料更乾淨、更準確 —— Antigravity 配額行去除雜訊、修正 zero-entitlement 方案的 Copilot 用量、修復 Augment 解析、Claude 讀數更穩。來自 CodexBar 0.32.4 同步。" + } + } + } + }, + "Antigravity — quota rows are cleaner: image / lite / autocomplete / internal noise rows no longer skew the summary bar.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Antigravity — quota rows are cleaner: image / lite / autocomplete / internal noise rows no longer skew the summary bar." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Antigravity — クォータ行がすっきり:image/lite/autocomplete/internal のノイズ行が集計バーを歪めなくなりました。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Antigravity —— 配额行更干净:image / lite / autocomplete / internal 噪声行不再干扰汇总进度条。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Antigravity —— 配額行更乾淨:image / lite / autocomplete / internal 雜訊行不再干擾彙總進度條。" + } + } + } + }, + "Copilot — zero-entitlement business tokens no longer show a misleading usage percentage.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copilot — zero-entitlement business tokens no longer show a misleading usage percentage." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Copilot — zero-entitlement のビジネストークンで誤解を招く使用率が表示されなくなりました。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Copilot —— zero-entitlement 的商业 token 不再显示误导性的用量百分比。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Copilot —— zero-entitlement 的商業 token 不再顯示誤導性的用量百分比。" + } + } + } + }, + "Augment — usage parses correctly again after the upstream status-format change.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Augment — usage parses correctly again after the upstream status-format change." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Augment — アップストリームのステータス形式変更後も使用状況が正しく解析されるようになりました。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Augment —— 上游状态格式变更后,用量重新可以正确解析。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Augment —— 上游狀態格式變更後,用量重新可以正確解析。" + } + } + } + }, + "Claude — a brief sign-in hiccup no longer blanks your usage; the last good reading is kept.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude — a brief sign-in hiccup no longer blanks your usage; the last good reading is kept." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude — 一時的なサインインの不調で使用状況が空白にならず、直近の有効な値を保持します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Claude —— 短暂登录波动不再清空用量,会保留最近一次有效读数。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Claude —— 短暫登入波動不再清空用量,會保留最近一次有效讀數。" + } + } + } + }, + "Codex / Claude cost — refreshed by the v0.32 cost-scanner update; your cost cards re-scan to the corrected numbers.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex / Claude cost — refreshed by the v0.32 cost-scanner update; your cost cards re-scan to the corrected numbers." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex/Claude コスト — v0.32 のコストスキャナー更新により再計算され、コストカードが修正後の数値で再スキャンされます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codex / Claude 成本 —— 经 v0.32 成本扫描器更新刷新,成本卡会重扫到修正后的数值。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codex / Claude 成本 —— 經 v0.32 成本掃描器更新重新整理,成本卡會重掃到修正後的數值。" + } + } + } + }, + "Update Mac CodexBar to 0.32.4 (fork build 79.1 or later). iPhone 1.11.0 stays forward-compatible with older Mac builds — these refinements simply arrive once Mac is updated.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Mac CodexBar to 0.32.4 (fork build 79.1 or later). iPhone 1.11.0 stays forward-compatible with older Mac builds — these refinements simply arrive once Mac is updated." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac 版 CodexBar を 0.32.4(fork build 79.1 以降)に更新してください。iPhone 1.11.0 は古い Mac ビルドとも前方互換で、これらの改善は Mac を更新すると反映されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将 Mac 版 CodexBar 更新到 0.32.4(fork build 79.1 或更高)。iPhone 1.11.0 仍向前兼容旧版 Mac —— 这些改进会在 Mac 更新后到达。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將 Mac 版 CodexBar 更新到 0.32.4(fork build 79.1 或更高)。iPhone 1.11.0 仍向前相容舊版 Mac —— 這些改進會在 Mac 更新後到達。" + } + } + } + }, + "Search providers": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search providers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロバイダーを検索" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索 provider" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋 provider" + } + } + } + }, + "No matching providers": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No matching providers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致するプロバイダーがありません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有匹配的 provider" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有符合的 provider" + } + } + } + }, + "No provider matches your search. Try a different name.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No provider matches your search. Try a different name." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索に一致するプロバイダーがありません。別の名前で試してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有 provider 匹配你的搜索。换个名称试试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有 provider 符合你的搜尋。換個名稱試試。" + } + } + } + }, + "Search — filter the Usage list by provider name; handy when many providers are synced.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search — filter the Usage list by provider name; handy when many providers are synced." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索 — Usage リストをプロバイダー名で絞り込み。多数のプロバイダーが同期されているときに便利。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索 —— 按 provider 名称过滤 Usage 列表;同步的 provider 很多时很方便。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋 —— 按 provider 名稱過濾 Usage 列表;同步的 provider 很多時很方便。" + } + } + } } }, "version": "1.0" diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md new file mode 100644 index 00000000..64bfe3bf --- /dev/null +++ b/CodexBarMobile/Research/026-v032-upstream-sync/00-overview.md @@ -0,0 +1,233 @@ +# 026 — v0.32.x 上游同步 · 总体文档 + +**Status:** ready +**Date:** 2026-06-03 +**Target release tag:** `v0.32.4.1-mobile.1.11.0`(MOBILE 段待 G4 确认,可能为 Mac-only `1.10.0`) +**Branch:** `upstream-sync/v0.32.4-mobile.1.11.0` +**文档集:** 本目录共 4 份 — +[00 总体](00-overview.md) · [01 设计](01-design.md) · [02 开发+架构](02-development.md) · [03 测试](03-testing.md) + +--- + +## ⭐ 最终目标版本号(锁定)+ 完成确认 + +> 本目标的**验收锚点**。每轮循环结束都对照此处:版本号是否 stamp 对、DONE 是否全勾。 +> 只有下方版本号已落定 **且** G1–G10 全部勾选,才可对用户宣告"全部工作已完成"。 + +**最终版本号(达成时必须 stamp 成这些值,依 `docs/versioning.md`):** + +| 端 | 最终版本 | 落点文件 | +|---|---|---| +| **Mac** | MARKETING `0.32.4.1` · BUILD `79.1`(上游 v0.32.4 BUILD=79 + fork `.1`)· UPSTREAM `v0.31.0`→`v0.32.4`(**发布后才 bump**,F1) | `version.env` | +| **iOS** | MOBILE_VERSION `1.11.0`(**若 G4 判定零 iOS 代码改动则保持 `1.10.0`、走 Mac-only**)· BUILD `148`+ | `CodexBarMobile/project.yml` + `version.env` | +| **Sparkle / Release tag** | `sparkle:version` `79.1.1.11.0` · tag `v0.32.4.1-mobile.1.11.0`(或 `…-mobile.1.10.0`) | appcast / GitHub release | + +> ⚠️ **关键设计决策(G4)**:本批上游**无新 wire 字段、无新 iOS 卡片**(见 §5)。iOS 端是 +> "纯验证 + 可选小增强"。发布前定:iOS 是否 ship 新 build(MOBILE→1.11.0)还是 Mac-only +> (MOBILE 保持 1.10.0,不上 TestFlight)。默认倾向 ship 配套 iOS(PM 指令"尽可能全支持" +> + release notes 刷新),但若确无 iOS 代码改动,Mac-only 亦合规。 + +**完成确认(DONE —— 全部勾选才算"全部工作完成"):** + +- [x] **G1 · Mac 合并**:`git merge v0.32.4`(67 commits)干净、`swift build` 绿(21s)、5 冲突全解 — 提交 `6d3e54d4`(R1) +- [x] **G2 · Codex parser 缓存失效**(本轮核心):`regenerate-codex-parser-hash.sh` → hash `518924b891f96a03` + `parserLogicVersion` 4→5;全量 `Scripts/lint.sh lint` 绿(parser-version + hash 审计均 OK)— 提交 `5d8f6167`(R1) +- [~] **G3 · 值修正自动透传验证**:Antigravity 配额行过滤(#1209)、Copilot 零权利 %(#1258)、Augment 解析(#1224)、Claude 快照保留(#1220)—— grep 确认在合并树 + `Shared/`/`Sync/` 相对 base 零 diff(经现有字段透传,无需 bridge)✓;iOS 实机可视化 = 用户 QA +- [x] **G4 · iOS 面定稿**:用户决策 = **配套 ship iOS 1.11.0**(纯 release-notes,无功能代码;值修正经同步到达 iOS)。MOBILE → 1.11.0,tag `v0.32.4.1-mobile.1.11.0`(R3) +- [x] **G5 · i18n / release notes**:`MobileReleaseNotesCatalog` 1.11.0 条目(5 项)+ 7 文案 ×4 语 xcstrings(314 keys 全在)+ root CHANGELOG 0.32.4.1 双语 + iOS CHANGELOG 1.11.0(148)(R3) +- [x] **G6 · 测试**:全量串行 `swift test --no-parallel` 绿(3630 tests / 417 suites);唯一失败 `KeychainPromptSafetyAuditTests` 是 mobile-dev 预存的 AGENTS.md 审计缺口(非合并回归),已修。并行 flake `SyncCoordinatorTests`(Index out of range)属已知(memory)。跨版本 iOS 实机 = 用户 QA +- [x] **G7 · Code Review**:独立 Opus 4.7 agent 评审 fork 改动(合并冲突解决 + parser 缓存失效)→ **SHIP**,零阻塞 findings(R1) +- [x] **G8 · 版本号 stamp**:`version.env`(MARKETING 0.32.4.1 / BUILD 79.1 / MOBILE 1.11.0)+ `project.yml`(1.11.0 / 148)+ `xcodegen` 重生成;iOS sim build 绿(R3) +- [x] **G9 · CloudKit 审计**:`CloudConstants.swift` 相对 base 零 diff、`providerPayloadVersion=1` 未变、无新 CKRecord 字段 → **无需 Prod schema deploy** ✓ +- [ ] **G10 · 发布**:Mac 签名公证 + draft→publish + appcast + 装机;(iOS TestFlight 若 ship);合并分支 → `mobile-dev`;关 issue #15/16/18/19/20;bump `version.env` UPSTREAM_VERSION + +**当前进度:9 / 10(G1–G9 ✓,G3 代码层已验证 + iOS 可视化属真机 QA;剩 G10 发布 = 用户环节)。** + +**`/goal` 自动循环完成条件**(每回合自动复检): + +```text +v0.32.x 同步达到「可发布前完成态」,且以下每项都在本会话由命令输出或文件内容证明, +四份文档 00–03 已回写到与代码一致: +(1) git merge v0.32.4 完成、git status 干净; +(2) swift build 退出 0、xcodebuild -scheme CodexBarMobile 构建成功; +(3) CostUsage 缓存失效已处理:CodexParserHash 重生成 + parserLogicVersion bump,全量 lint.sh lint 绿; +(4) swift test 全绿(含 cost-cache 失效 + 跨版本兼容场景); +(5) Scripts/lint.sh 通过、(若 iOS ship)xcstrings 4 语齐、无 state:"new"; +(6) version.env = MARKETING 0.32.4.1 / BUILD 79.1 / MOBILE(1.11.0 或 1.10.0)/ UPSTREAM v0.31.0(发布前不 bump);project.yml stamp 对; +(7) 本 ⭐ 节 DONE 计数 = 9/10(G1–G9 勾选)、四份文档「修订记录」已更新到本轮; +(8) 最近一轮做过防回归复验且通过。 +到 9/10 即停并交回用户;或在 40 回合后停止并汇报当前 X/10。 +``` + +--- + +## 1. 一句话目标 + +把上游 `steipete/CodexBar` 从 **v0.31.0 → v0.32.4**(即 `v0.32.0/0.32.1/0.32.2/0.32.3/0.32.4` 五个 tag,对应 open issue #15/#16/#18/#19/#20)**所有用户可见的显示数据 + 数值修正**同步到 fork 的 Mac 与 iOS 端,**一次合并发布**,不拆版本。 + +宗旨(PM 指令):**只要 Mac 端新增的显示内容 iOS 能显示,就尽可能全部支持;除非与现有基础架构完全冲突才放弃。** + +--- + +## 2. 当前状态 / 起点 + +| 维度 | 当前值 | 来源 | +|---|---|---| +| 已对齐上游 tag | `v0.31.0` | `version.env: UPSTREAM_VERSION` | +| Mac MARKETING_VERSION | `0.31.0.2` | `version.env` | +| Mac BUILD_NUMBER | `73.2` | `version.env` | +| MOBILE_VERSION | `1.10.0` | `version.env` | +| iOS project.yml | MARKETING `1.10.0` / BUILD `147` | `CodexBarMobile/project.yml` | +| 上游 v0.32.4 BUILD | `79`(appcast `sparkle:version`) | upstream `v0.32.4:appcast.xml` | + +--- + +## 3. 范围(open issue 驱动) + +`gh issue list --state open --label upstream-sync` → #15(v0.32.0) · #16(v0.32.1) · #18(v0.32.2) · #19(v0.32.3) · #20(v0.32.4)。整合成一次合并到 `v0.32.4`。 + +上游 `v0.31.0..v0.32.4` = **67 commits / 122 files / +8211 -699**(大头在 Mac UI/perf/tests/docs + Codex parser 重写)。 + +--- + +## 4. 上游逐版本变更摘要(仅列与显示/数据相关者) + +### v0.32.0(#15) +- **Antigravity OAuth 配额行过滤**(#1209)— 过滤噪声远程 OAuth 配额行,仅显示已消耗行,阻止 image/lite/autocomplete/internal 行污染汇总进度条。**改变显示数据**,经现有 Antigravity 透传。 +- **Copilot**(见 v0.32.3 #1258 修复);**Augment 解析更新 + cookie fallback**(#1224)— 数据源修正,经现有字段透传。 +- **Claude 保留最后有效 Web 用量快照**(#1220)— 短暂 Unauthorized 期间不清零,可靠性/新鲜度。 +- **Settings Provider 搜索**(#1184)— Mac UI;iOS 可选小增强(P3)。 +- 其余(Amp/Ollama HTTPS cookie 安全 #1226、CLI 临时脚本隔离 #1222、Codex WebKit 刷新取消 #1217、Menu Codex 附件刷新 #1150、菜单栏定位 #1216/#1227、公证路径隔离 #1228、Status 启动重试 #1211)— Mac 安全/性能/可靠性,**无新 iOS 显示字段**。 + +### v0.32.1(#16) +- **全部 Mac 可靠性/性能**:Claude OAuth refresh-token 委托 CLI(#1239,防强制重登)、菜单栏性能、输入响应、启动稳定。**无新显示字段。** + +### v0.32.2(#18) +- **Codex token-cost 扫描器优化**(性能)— **触及 Codex parser**(`CostUsage/`),见 §5 #1 缓存失效。 +- QA 文档、菜单栏留白。**无新显示字段。** + +### v0.32.3(#19) +- **Copilot 零权利(zero-entitlement)配额修复**(#1258)— 防止显示误导性用量百分比。**数值/显示修正**,经现有 Copilot 字段透传。 +- 菜单栏定位、SVG 缓存、菜单响应、OpenAI Web 稳定性 — Mac 性能/可靠性。 + +### v0.32.4(#20) +- **菜单栏 provider 刷新优化**(#1277)— Mac-only,**无新显示字段**。 + +--- + +## 5. 特性清单 → 同步路径 → fork 工作量 + +> 三条路径:**(A) 通用 lane 自动透传**(进 `rateWindows[]`,iOS 通用渲染);**(B) 数值修复自动透传**(合并即经现有字段纠正);**(C) 新 envelope**(新 optional `SyncXxx` + 新 iOS 卡)。 + +| # | 特性 | 版本 | 路径 | fork 工作量 | +|---|---|---|---|---| +| 1 | **Codex parser 重写**(FastJSON #?, truncated prefix, 扫描性能) | 0.32.0–0.32.2 | **缓存失效** | **regenerate CodexParserHash + bump parserLogicVersion**(本轮唯一必须的 fork 代码改动) | +| 2 | **Antigravity 配额行过滤** #1209 | 0.32.0 | A 自动 | 无(透传);验证 iOS Antigravity 卡显示过滤后的行 | +| 3 | **Copilot 零权利 %** #1258 | 0.32.3 | B 自动 | 无;验证 iOS Copilot % 不再误导 | +| 4 | **Augment 解析 + cookie fallback** #1224 | 0.32.0 | B 自动 | 无;验证 iOS Augment 数值 | +| 5 | **Claude 快照保留** #1220 | 0.32.0 | B 自动 | 无;验证 iOS Claude 不闪空/旧 | +| 6 | **Claude OAuth refresh 委托** #1239 | 0.32.1 | B 自动(Mac 认证) | 无 iOS 显示影响(Mac 凭证健康) | +| 7 | **Settings Provider 搜索** #1184 | 0.32.0 | — | Mac UI;iOS 可选 provider 列表搜索(P3,默认跳过,除非 G4 决定做) | +| — | 菜单栏/性能/安全/CLI/release | 0.32.x | — | Mac-only,N/A iOS | + +**结论:本批 fork 侧极轻 —— 无新 wire envelope、无新 iOS 卡片。** 唯一必须的代码改动是 +**Codex parser 缓存失效(G2)**;其余全是经现有 synced 字段自动透传的数值修正(验证即可)。 +iOS 可能**零代码改动**(→ Mac-only),或仅做可选 provider 搜索 + release notes 刷新。 + +详细字段落点见 [01 设计文档](01-design.md)。 + +--- + +## 6. 版本目标(依 `docs/versioning.md`) + +| 变量 | From | To | 规则 | +|---|---|---|---| +| `MARKETING_VERSION`(Mac) | `0.31.0.2` | **`0.32.4.1`** | 前 3 段照抄上游 `v0.32.4`;fork 段回 `.1` | +| `BUILD_NUMBER`(Mac) | `73.2` | **`79.1`** | 上游 v0.32.4 BUILD=79 + fork `.1` | +| `MOBILE_VERSION` | `1.10.0` | **`1.11.0`**(或保持 `1.10.0` Mac-only) | 待 G4 定 | +| `UPSTREAM_VERSION` | `v0.31.0` | **`v0.31.0`→`v0.32.4`(G10 发布后)** | confirmed-shipped,发布后才 bump | +| iOS `CURRENT_PROJECT_VERSION` | `147` | **`148`+** | 每次 commit +1 | +| `sparkle:version` | `73.2.1.10.0` | **`79.1.1.11.0`**(或 `79.1.1.10.0`) | `BUILD.MOBILE` 5 段单调递增 | +| Release tag | `v0.31.0.2-mobile.1.10.0` | **`v0.32.4.1-mobile.1.11.0`** | `v{MARKETING}-mobile.{MOBILE}` | + +--- + +## 7. 阶段计划(PM 6 步落进循环) + +| 阶段 | 内容 | 闸门 | +|---|---|---| +| **A. Mac 合并** | `git merge v0.32.4`;解 fork-owned 冲突;`swift build` 绿 | 干净构建 | +| **B. parser 缓存失效** | regenerate hash + bump parserLogicVersion;全量 lint | lint 绿 | +| **C. 值修正透传 + iOS 面定** | grep 验证 #1209/#1258/#1224/#1220 经现有字段流过;定 iOS scope + MOBILE 版本 | 设计 ready | +| **D. Mac 草稿发布** | CloudKit 审计;sign-notarize;appcast | draft(用户 Mac 凭证) | +| **E. Mac 端到端 + 回归** | 全量 `swift test`;cost-cache 失效验证;跨版本同步走查 | 无回归 | +| **F. iOS(若 ship)** | project.yml bump + xcodegen;4 语 + release notes;冒烟 + lint | iOS 构建 | +| **G. 发布 + 收尾** | TestFlight(若 ship);publish + appcast;合并 mobile-dev;关 issue | 用户手里可装 | + +**CR 闸门**:每关键阶段后独立 Opus 4.7 agent CR loop,清干净再 bump 版本打包。 + +--- + +## 8. 风险 + +| # | 风险 | 缓解 | +|---|---|---| +| R1 | **CostUsage parser 大改(+903)→ 缓存失效轴漏滚** | 本轮已知必做 G2:regenerate hash + bump parserLogicVersion + 全量 lint(吸取 0.31.0.1 教训,见 memory `parser-cache-invalidation-on-upstream-merge`) | +| R2 | 67 commit 合并引入旧特性回归 | 阶段 E 全量回归 + 全 `swift test` | +| R3 | Antigravity 行过滤 #1209 改变现有显示 → iOS 旧缓存/旧 Mac 混用时不一致 | 跨版本兼容场景(03);经现有 rateWindows 透传,加 mock | +| R4 | Copilot #1258 零权利 % 修复后 iOS 端显示口径变化 | 验证 iOS Copilot 卡;属 B 自动透传 | +| R5 | 无新 wire 字段却误触 CloudKit schema | 初判否(无新 CKRecord 字段);阶段 D 正式审计 | +| R6 | iOS 实为零代码改动却强行 ship 1.11.0 | G4 显式决策 Mac-only vs iOS ship | + +--- + +## 9. 与项目护栏的一致性 + +- 不改上游 Mac-only 逻辑:仅 `git merge` + fork-owned `Sources/CodexBar/Sync/`(bridge)+ `Shared/`(wire)+ `CodexBarMobile/`。`Sources/CodexBarCore/` 上游内容只读(含 CostUsage —— 只跑 regenerate 脚本 + 改 `parserLogicVersion`,不改 parser 逻辑)。 +- 不推 upstream,只推 `origin`。不跳本地化(若 iOS ship,4 语齐)。每次 commit bump `CURRENT_PROJECT_VERSION`。不手编 .xcodeproj(`xcodegen`)。 +- **Definition of Done** = 已签名公证 + 发到用户手里(见 `docs/RELEASE-CHECKLIST.md`),不是 commit。 +- **CR before package**:清干净再打包。 +- **parser 缓存失效护栏**:见 R1 + memory。 + +--- + +## 10. 执行轮次记录(Round log) + +### Round 0 — 起步(2026-06-03) +- 读 5 个 open issue(#15/16/18/19/20)定范围 = v0.32.0→v0.32.4 一次合并。`git fetch upstream --tags` 拉到 v0.32.x。 +- 上游 `v0.31.0..v0.32.4` = 67 commits / 122 files / +8211 -699。**关键画像**:无新 provider、无 `UsageSnapshot`/`Shared/Models` 新字段 → **无新 wire envelope、无新 iOS 卡片**;CostUsage parser 大改(+903,含新 `CostUsageScanner+CodexFastJSON.swift`)→ 必做 G2 缓存失效。 +- 版本目标定(依 `docs/versioning.md`):MARKETING `0.32.4.1` / BUILD `79.1`(上游 79)/ MOBILE `1.11.0`(待 G4)/ tag `v0.32.4.1-mobile.1.11.0`。 +- 建分支 `upstream-sync/v0.32.4-mobile.1.11.0`,生成本文档集 00–03 + PROJECT-PROMPT.md。**进度 0/10**,下一步进 Round 1(Phase A:`git merge v0.32.4`)。 + +### Round 1 — Phase A 合并 + Phase B parser 缓存失效(2026-06-03) +- **G1 ✓**:`git merge v0.32.4`(67 commits)→ 5 冲突全解:`version.env`(→0.32.4.1/79.1)、`CHANGELOG.md`(两侧都留)、`appcast.xml`(ours)、`CodexParserHash.generated`(ours,待 regenerate)、`sign-and-notarize.sh`。`swift build` 绿(21s)。提交 `6d3e54d4`。核心 fork 代码(`Shared/`、`Sync/`)零冲突。 +- **发现 F1(sign-and-notarize.sh 冲突,release 脚本坑)**:上游 #1228 把公证 API key/zip 隔离到私有临时目录,且下游公共代码改用 `$API_KEY_PATH`/`$NOTARIZATION_ZIP`(只在上游块定义)。但上游块**只认 `_P8`**,而 fork/用户用 `_FILE`。解法:采纳 #1228 私有目录隔离 + 定义两变量,但**保留 fork 的 `_FILE`/`_P8` 双支持 + fork 的 mobile 后缀 `ZIP_NAME`/`DSYM_ZIP`**(不用上游 `codexbar_app_zip_name`,否则 release.sh/appcast 找不到 zip)。属 release-time 风险,build/test 抓不到(memory `fork-script-conflict`),Phase D 打包时复核。 +- **G2 ✓**:CostUsage 确认大改(+903/-91,新 `CostUsageScanner+CodexFastJSON.swift`)。bump `parserLogicVersion` 4→5(+ v5 history 注释)→ `regenerate-codex-parser-hash.sh` → hash `518924b`。坑:`audit-parser-version` 查 `base...HEAD` 已提交 diff,故须**先提交**缓存失效改动审计才认(merge commit 里还是 4)。提交 `5d8f6167` 后全量 lint 绿。 +- **进度 2/10**。下一步:G3(值修正透传 grep 验证)+ G9(CloudKit 审计 grep)+ G6(swift test 回归)+ G7(Opus CR)。 + +### Round 2 — G3/G6/G7/G9 验证(2026-06-03) +- **G3 ✓(代码层)**:Antigravity #1209 / Copilot #1258(`ffd8d75a`)/ Augment #1224(`4a2ef3ae`)在合并树;`Shared/`+`Sources/CodexBar/Sync/` 相对 base **零 diff** → 值修正经现有 synced 字段透传,无需 bridge/wire 改动。iOS 可视化 = 用户真机 QA。 +- **G9 ✓**:`CloudConstants.swift` 零 diff、`providerPayloadVersion=1` 未变 → 无新 CKRecord 字段 → **无需 Prod schema deploy**。 +- **G6 ✓**:全量串行 `swift test --no-parallel` = 3630 tests / 417 suites,唯一失败是 `KeychainPromptSafetyAuditTests`(断言 AGENTS.md 含 keychain-prompt 安全指引)。查实:**mobile-dev 早就缺这两句、测试早就在 fail(非本次合并回归)**;其余 3629 全过。修法:把上游那条安全指引加进 fork AGENTS.md Step 4(提交 `d9b746f8`)→ `KeychainPromptSafetyAuditTests` 4/4 过。并行 `SyncCoordinatorTests` flake(Index out of range)属已知 memory。 +- **G7 ✓**:独立 Opus 4.7 agent 评审合并冲突解决 + parser 缓存失效 → **SHIP**,零阻塞。确认 `sign-and-notarize.sh` 所有变量 set-u 下用前已定义、`codexbar_app_zip_name` 无人调用、parser 双轴失效 `regenerate --check` 通过。 +- **进度 6/10**。剩 **G4 iOS scope(用户决策:Mac-only vs 配套 ship 1.11.0)** → G5/G8(依 G4)→ G10 发布(用户环节)。 + +### Round 3 — G4 决策 + G5/G8 iOS 1.11.0 收尾(2026-06-03) +- **G4 ✓**:用户定 = **配套 ship iOS 1.11.0**(纯 release-notes,无功能代码)。锁定 MOBILE 1.11.0、tag `v0.32.4.1-mobile.1.11.0`、sparkle `79.1.1.11.0`。 +- **G5 ✓**:`ContentView` `MobileReleaseNotesCatalog` 加 1.11.0 条目(Antigravity 行 / Copilot % / Augment / Claude 快照 / Codex-Claude 成本重扫 5 项 + Required Mac),1.10.0 取消 Latest;7 文案 ×4 语加进 xcstrings(Python 零 churn,314 source keys 全在);root CHANGELOG 0.32.4.1 双语(changelog-to-html 渲染干净)+ iOS CHANGELOG 1.11.0(148)。 +- **G8 ✓**:`version.env` MOBILE→1.11.0;`project.yml` 1.11.0 / 148;`xcodegen` 重生成。全量 lint 绿、iOS sim build SUCCEEDED。提交 `3d59278f`。 +- **进度 9/10**。剩 **G10 发布**(用户环节):Mac sign-notarize→draft→publish+appcast→装机 + iOS 1.11.0(148) TestFlight + 合并 mobile-dev + 关 issue #15/16/18/19/20 + bump UPSTREAM_VERSION→v0.32.4。**Phase D 打包时复核 F1 的 sign-and-notarize.sh widget/notarize 改动。** + +### Round 4 — iOS Usage provider 搜索(用户加需求,2026-06-04) +- 用户反馈:20+ provider 时 Usage 列表滑动找 provider 麻烦 → 在 Usage tab 顶部加 `.searchable` 搜索栏(`.navigationBarDrawer(.always)`),按 `providerName`/`providerID` 过滤 `groups`(空查询 = 全量,零行为变化),无匹配显示 `EmptyStateView`。**linkage / 多账号分组仍用全量 `liveProviders`,过滤只隐藏行、不丢 linkage 提示**(Opus CR 专门确认)。 +- 4 个新文案 ×4 语;in-app 1.11.0 release-notes 加"搜索"项;root + iOS CHANGELOG;`project.yml` build 148→149。 +- 验证:`Scripts/lint.sh lint` 绿(source keys 全在 + 4 语齐)、iOS sim build SUCCEEDED、独立 Opus CR → **SHIP**。提交 `811f9c46`。 +- **iOS scope 修正**:本批不再是"零功能代码" —— 新增 provider 搜索(即 #1184 在只读 companion 上有意义的形态)。iOS 最终 build = **149**,tag 仍 `v0.32.4.1-mobile.1.11.0`。 +- 进度仍 **9/10**(G10 发布 = 用户环节,待授权)。G10 的 iOS TestFlight 上传 build **149**(非 148)。 + +### Round 5 — G10 部分:Mac Draft + 装机 + iOS TestFlight(用户授权,2026-06-04) +- 用户授权:Mac 出 Draft Release + 装机;iOS 传 TestFlight(明确"Draft",未授权 publish)。 +- **Mac phase1**(`release.sh`):lint 绿 → build → Developer ID 签名 → **Apple 公证 Accepted + staple + validate** → launch 验证 OK → `CodexBar-0.32.4.1-mobile.1.11.0.zip`。**R1 的 `sign-and-notarize.sh` 合并解法(#1228 私有临时目录 + fork 双 `_FILE`/`_P8` 密钥 + fork mobile 后缀 ZIP_NAME)+ widget 打包首次真打包验证通过 → F1 风险解除。** tag `v0.32.4.1-mobile.1.11.0` 已推、draft 已建(`untagged-9aea4c9cc9f60b5cc9e4`)。 +- 产物验证:`0.32.4.1` / `79.1.1.11.0`、widget `CodexBarWidget.appex` 已签、CloudKit entitlement = **Production**、Gatekeeper accepted。装到 `/Applications/CodexBar.app` 并启动(运行中)。 +- **iOS build 149**:archive + cloud-sign + 上传 ASC 成功(EXPORT SUCCEEDED),TestFlight 处理中。in-app 1.11.0 release notes 已确认含搜索 + 5 项值修正(4 语)。 +- **未做(等用户审 draft 后授权 publish)**:publish draft live + 推 appcast(Sparkle)+ close issue #15/16/18/19/20 + bump `version.env` UPSTREAM_VERSION→v0.32.4 + 合并分支→mobile-dev。 +- 进度:**G10 部分完成**(draft + 装机 + iOS TestFlight);剩 publish 收尾(用户授权后)。 diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/01-design.md b/CodexBarMobile/Research/026-v032-upstream-sync/01-design.md new file mode 100644 index 00000000..f3ca62ff --- /dev/null +++ b/CodexBarMobile/Research/026-v032-upstream-sync/01-design.md @@ -0,0 +1,60 @@ +# 026 — v0.32.x 同步 · 设计文档 + +[00 总体](00-overview.md) · **01 设计** · [02 开发+架构](02-development.md) · [03 测试](03-testing.md) + +--- + +## 1. 一句话设计 + +本批上游**无新 wire 字段、无新 iOS 卡片**([00 §5](00-overview.md) 已确认:无新 provider、`UsageSnapshot`/`Shared/Models` 零变动)。设计 = 三件事: +1. **Codex parser 缓存失效**(唯一必须的 fork 代码改动); +2. **值修正经现有 synced 字段自动透传**(验证为主,零 iOS 代码); +3. **iOS scope 决策**(Mac-only vs ship 1.11.0)。 + +--- + +## 2. 字段级落点(逐特性) + +### 2.1 Codex parser 重写 → 缓存失效(路径:缓存失效,非 wire) +- 上游改了 `Sources/CodexBarCore/Vendored/CostUsage/`(+903/-91,含新 `CostUsageScanner+CodexFastJSON.swift`、`CostUsageScanner.swift` +646、`CostUsagePricing.swift` +10)。 +- **不新增 wire 字段**:Codex/Claude 成本仍经现有 `SyncCostSummary` / 成本卡同步。 +- **失效轴**(见 `CostUsageCache.swift`): + - `producerKey`(codex-only)= `"codex:cu:p"` → 跑 `Scripts/regenerate-codex-parser-hash.sh` 滚动。 + - `pricingFingerprint`(全 provider,Claude 唯一轴)= `"v|codex=…|claude=…"` → bump `parserLogicVersion` 滚动(`CostUsagePricing.swift` 若新增定价条目也会滚,但仍显式 bump 以覆盖 Claude scanner 改动)。 +- **落点**:`CodexParserHash.generated.swift`(脚本生成)+ `CostUsagePricing.swift` 的 `parserLogicVersion N→N+1` + 历史注释。**Mac 端缓存重扫 → 纠正后的成本数据自动同步到 iOS(零 iOS 改动)。** + +### 2.2 Antigravity 配额行过滤 #1209(路径 A 自动透传) +- Mac 侧 `AntigravityStatusProbe.swift`(+160)过滤噪声 OAuth 配额行后,经现有 `extraRateWindows` / `rateWindows[]` 同步;iOS `ProviderUsageView.ForEach(allRateWindows)` 自动渲染过滤后的行。**零 iOS 代码**;验证 iOS Antigravity 卡显示更干净。 + +### 2.3 Copilot 零权利 % #1258(路径 B 自动透传) +- Mac `CopilotUsageFetcher.swift`(+15)修正 zero-entitlement 场景的 %;经现有 Copilot synced 字段透传。**零 iOS 代码**;验证 iOS Copilot 卡不再误导。 + +### 2.4 Augment 解析 + cookie fallback #1224(路径 B) +- Mac `Auggie*`/`Augment*`(解析格式更新 + 浏览器 cookie fallback);数值经现有 Augment synced 字段透传。**零 iOS 代码**;验证数值正确。 + +### 2.5 Claude 快照保留 #1220 / OAuth 委托 #1239(路径 B,可靠性) +- Mac `ClaudeOAuthCredentials.swift`(+98)等;短暂 Unauthorized 不清零 + refresh-token 委托 CLI。提升 Mac 端数据新鲜度/凭证健康,经现有 Claude synced 字段透传。**零 iOS 代码**;验证 iOS Claude 不闪空。 + +--- + +## 3. iOS scope 决策(G4) + +| 选项 | 内容 | MOBILE | 适用 | +|---|---|---|---| +| **A. Mac-only** | iOS 零代码改动,值修正经同步自动到达 iOS;不发 iOS build | `1.10.0` 不动 | 若确认无任何 iOS 可见增量 | +| **B. iOS 配套 ship**(默认倾向) | 零功能代码,但刷新 `MobileReleaseNotesCatalog`(1.11.0 条目说明本批值修正)+ 版本 bump,配套上 TestFlight | `1.11.0` | PM"尽可能全支持" + release notes 一致性 | +| **C. iOS + 可选增强** | B + 实现 provider 列表搜索(对应上游 Settings 搜索 #1184) | `1.11.0` | 仅当用户明确要这个增强 | + +**默认走 B**(配套 ship,零功能代码,只 release notes + 版本)。Round 1 合并后复核确无 iOS 代码改动需求即锁定 B;若用户要 provider 搜索则转 C。 + +--- + +## 4. Mock / i18n + +- **无新结构 → 无需新 mock**(除非走 C 加 provider 搜索)。 +- **i18n**:仅当走 B/C 且新增 `MobileReleaseNotesCatalog` 1.11.0 条目时,新文案 4 语(en/zh-Hans/zh-Hant/ja),照 025 的 xcstrings 加法(json.load → 加 key → dump 不排序,零 churn)。 + +--- + +## 修订记录 +- **Round 0(2026-06-03)**:初稿。确认无新 wire/卡片;设计聚焦 parser 缓存失效 + 值修正透传验证 + iOS scope 决策(默认 B 配套 ship)。 diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/02-development.md b/CodexBarMobile/Research/026-v032-upstream-sync/02-development.md new file mode 100644 index 00000000..f2bd0661 --- /dev/null +++ b/CodexBarMobile/Research/026-v032-upstream-sync/02-development.md @@ -0,0 +1,66 @@ +# 026 — v0.32.x 同步 · 开发 + 架构 + +[00 总体](00-overview.md) · [01 设计](01-design.md) · **02 开发+架构** · [03 测试](03-testing.md) + +--- + +## 1. Phase A — 合并(Round 1) + +`git checkout upstream-sync/v0.32.4-mobile.1.11.0`(已建,从 mobile-dev)→ `git merge v0.32.4`。 + +**预期冲突面(照 fork 历史解):** +- **Fork 元文件**:`AGENTS.md`(ours)、`CHANGELOG.md` / `README*.md`(两侧都留)、`appcast.xml`(ours)、`version.env`(目标值)。 +- **Mac 发布脚本**(`package_app.sh` / `sign-and-notarize.sh` / `compile_and_run.sh`):保留 fork 手写 widget/CloudKit 打包;**但要确认 ours 没依赖上游已删函数**(memory `fork-script-conflict` + 025 R9 的 `generate_widget_appintents_metadata` 教训)。 +- **CostUsage(`Sources/CodexBarCore/Vendored/`)**:**这是上游代码,取 upstream 整块**(fork 不拥有 parser 逻辑)。合并后再跑缓存失效脚本。 +- **核心 fork 代码**(`Shared/`、`Sources/CodexBar/Sync/`、`CodexBarMobile/`):预期零冲突(本批无新 wire 字段)。 + +闸门:`swift build` 绿。 + +--- + +## 2. Phase B — Codex parser 缓存失效(Round 1/2,本轮核心) + +合并后 CostUsage 必然变化(+903)。**必做两步**(memory `parser-cache-invalidation-on-upstream-merge`): + +```bash +bash Scripts/regenerate-codex-parser-hash.sh # 滚动 Codex producerKey(hash → 新值) +# 编辑 Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift: +# static let parserLogicVersion = N → N+1 (当前 4 → 5) +# + 在 History 注释加 `- 5 (0.32.4.1): v0.32.x Codex 扫描器重写…` 一条 +bash Scripts/regenerate-codex-parser-hash.sh # parserLogicVersion 改完再跑一次(脚本 hash 整个 CostUsage 目录) +Scripts/lint.sh lint # 全量:swiftformat + swiftlint + i18n + parser-version + parser-hash +``` + +> **顺序坑**(025 踩过):先 bump parserLogicVersion 再 regenerate hash(脚本 hash 整个 `Vendored/CostUsage` 目录,含 CostUsagePricing.swift),否则要 regenerate 两次。 +> **为什么两轴都要**:Codex 走 producerKey(hash),Claude 只走 pricingFingerprint(parserLogicVersion)。只滚 hash 治不了 Claude。 +> **为什么 lint 要全量**:`audit-parser-version` 是 base...HEAD 前向的,合并把 parser 改动落在 base 上抓不到;只有 `audit-parser-hash`(绝对)能抓。 + +--- + +## 3. Phase C — bridge / wire(预期零改动) + +本批无新显示字段 → `Shared/Models/`、`SyncCoordinator.swift` 预期不动。合并后 grep 确认: +```bash +git diff f^..HEAD -- Shared/ Sources/CodexBar/Sync/ # 应只有冲突解决,无新 mapper +``` +若上游某 provider 的现有 synced 字段语义变了(如 Antigravity 行过滤改变 rateWindows 内容),属数据内容变化而非 schema 变化,无需 bridge 改动。 + +--- + +## 4. Phase D — CloudKit 审计 + +按 `docs/cloudkit-deploy-audit.md`:本批**无新 CKRecord 字段 / record type / zone / 索引**(无新 wire 结构,`CloudConstants.swift` 不动)→ **预判无需 Prod schema deploy**。发布前 grep 确认 `CloudConstants` 未变 + `providerPayloadVersion` 不变,历史存档记一笔。 + +--- + +## 5. 版本 stamp + 工程 + +- `version.env`:MARKETING `0.32.4.1` / BUILD `79.1`(MOBILE 待 G4;UPSTREAM 发布后才 bump)。 +- `CodexBarMobile/project.yml`:`CURRENT_PROJECT_VERSION` 147 → 148+(每 commit +1)。 +- `xcodegen generate --spec CodexBarMobile/project.yml`。 +- CHANGELOG:root(0.32.4.1 段,双语、converter-clean)+ iOS(若 ship,build 148 段)。 + +--- + +## 修订记录 +- **Round 0(2026-06-03)**:初稿。合并冲突面预判 + parser 缓存失效标准流程(含顺序坑)+ CloudKit 预判无需 deploy。 diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/03-testing.md b/CodexBarMobile/Research/026-v032-upstream-sync/03-testing.md new file mode 100644 index 00000000..4fbf29cf --- /dev/null +++ b/CodexBarMobile/Research/026-v032-upstream-sync/03-testing.md @@ -0,0 +1,45 @@ +# 026 — v0.32.x 同步 · 测试 + +[00 总体](00-overview.md) · [01 设计](01-design.md) · [02 开发+架构](02-development.md) · **03 测试** + +--- + +## 1. Codex parser 缓存失效(本轮核心,可自动验证) + +- `swift test --filter CostUsageCacheTests` 全绿(含 "pricingFingerprint includes parser logic version"、"rolls when price changes"、"non codex cache does not require producer key")。 +- 全量 `Scripts/lint.sh lint`:`Codex parser hash is current (<新hash>)` + `parser-version audit` 通过。 +- **语义验证**:parserLogicVersion N→N+1 使 `pricingFingerprint` 变 → 升级用户 Codex+Claude 成本缓存失效重扫(对照 0.31.0.1→0.31.0.2 的修复路径)。 + +## 2. 跨版本兼容(2 Mac × 2 iOS,用户真机 QA) + +| Mac \ iOS | iOS 1.10.0(旧) | iOS 1.11.0/Mac-only(新) | +|---|---|---| +| **73.2(旧)** | 现状基线 | 旧 Mac 不发新内容,新 iOS 回退渲染 | +| **79.1(新)** | 新 Mac 值修正经同步到旧 iOS,**旧 iOS 通用渲染不崩** | 全新组合 | + +重点: +- **Antigravity 行过滤 #1209**:新 Mac 发过滤后的 rateWindows,旧/新 iOS `ForEach(allRateWindows)` 都正常渲染(行变少,不崩)。 +- **Copilot % #1258**:新 Mac 发修正后的 %,iOS 显示正确口径。 +- 任意组合**无崩溃 / 无丢数据**。 + +## 3. 回归(防 67 commit 引入旧特性回归) + +- 全量 `swift test`:注意 `SyncCoordinatorTests` 并行 flake(memory `swift-test-parallel-flake`)—— `--no-parallel --filter SyncCoordinatorTests` 串行确认。 +- 逐 provider / 菜单 / 设置走查(Mac);CloudKit Mac→iOS sim 同步。 +- 重点查 Codex 成本卡(parser 重写后)数值合理、std/fast/Spark lane 不回退。 + +## 4. 值修正可视化验证(用户真机 QA) + +- Antigravity 卡:配额行更干净(无 image/lite/autocomplete/internal 噪声行)。 +- Copilot 卡:zero-entitlement 账户不再显示误导 %。 +- Augment 卡:解析更新后数值正确。 +- Claude:短暂 Unauthorized 期间不闪空/不清零。 + +## 5. iOS(若 G4 走 ship) + +- `xcodebuild -sdk iphonesimulator` 冒烟;`MobileReleaseNotesCatalog` 1.11.0 条目 4 语渲染;`Scripts/lint.sh` i18n 全译无 `state:"new"`。 + +--- + +## 修订记录 +- **Round 0(2026-06-03)**:初稿。测试矩阵聚焦 parser 缓存失效 + 跨版本透传不崩 + 值修正真机验证。 diff --git a/CodexBarMobile/Research/026-v032-upstream-sync/PROJECT-PROMPT.md b/CodexBarMobile/Research/026-v032-upstream-sync/PROJECT-PROMPT.md new file mode 100644 index 00000000..0e98823b --- /dev/null +++ b/CodexBarMobile/Research/026-v032-upstream-sync/PROJECT-PROMPT.md @@ -0,0 +1,19 @@ +# CodexBar Mobile — 上游同步 · 自治循环驱动(高阶提示词) + +> 本轮(026)的 `/goal` 驱动。本文件是提示词副本;规格在 00–03 + 仓库文档里。 + +你是 CodexBar Mobile 的开发 + 发布代理。**所有规格——流程、版本号、护栏——都在仓库文档里;你的职责是:读文档 → 按文档做 → 把进度和发现写回文档 → 重复,直到发到用户手里。不要在本提示词里重复文档内容。** + +**范围怎么来:** 仓库有自动化流程,会把每个上游新版本建成一个「上游同步」issue。所以跑 `gh issue list --repo o1xhack/CodexBar-Mobile --state open --label upstream-sync`,所有 open 项就是本轮范围——整合成一次合并(Mac + iOS 同一版本,不拆),取最高 tag 为目标。逐个 `gh issue view` 读正文定特性清单。 + +**事实来源(照做即可):** `AGENTS.md` + `CLAUDE.md`(完整流程 + 护栏)、`docs/versioning.md`(版本号规则)、`docs/RELEASE-CHECKLIST.md`(Definition of Done + 验收清单)、`docs/cloudkit-deploy-audit.md`(是否需 Prod deploy)。 + +**Round 0(只一次):** 按 open issue 范围建分支,并在 `CodexBarMobile/Research/<下一个编号>-<目标tag>-upstream-sync/` 自动生成调研文档集(照上一轮 `025-v031-upstream-sync/` 的四份结构:`00` 目标/范围/特性清单→同步路径/DONE 清单 + `/goal` 条件、`01` 字段级设计、`02` 开发+架构、`03` 测试矩阵)。写完即进循环。 + +**每一大轮:** ① 读四份文档 + DONE 计数 + `git status` 定位下一单元 → ② 做+测+**独立 Opus 4.7 agent CR loop 到零 findings**(没干净不许打包)→ ③ 回写文档(进度/发现/决策+修订记录,没回写=没完成)→ ④ 复跑 build+test 防回归 → ⑤ 重复,直到 DONE 清单全满足。 + +**工作顺序(PM 指定):** ① Mac 全量同步、完全兼容上游,期间定 iOS 要做什么(尽可能全支持)→ ② Mac 补齐 iOS 显示所需(wire+bridge+mock)→ ③ Mac draft release(版本号按 `docs/versioning.md`)→ ④ 测 Mac(新+老+回归,查改动是否带来老功能 BUG)→ ⑤ iOS 同样四步、一个版本覆盖全部不拆 → ⑥ 收口:测试完善、彻底解决兼容、新功能完美。 + +**特别注意(本轮踩过的坑):** 合并若动了 `Sources/CodexBarCore/Vendored/CostUsage/`(Codex/Claude 成本 parser),必须 `Scripts/regenerate-codex-parser-hash.sh` + bump `parserLogicVersion`,并跑**全量** `Scripts/lint.sh lint`,否则升级用户成本缓存不失效。 + +**完成判据:** 发到用户手里(Mac 签名公证+Sparkle appcast + iOS TestFlight),不是 commit 了。遇用户环节(TestFlight/凭证/签名/CloudKit deploy 决策)停下交回。发布后关掉本轮 open issue + bump `version.env` UPSTREAM_VERSION。每轮用**中文**简报。 diff --git a/CodexBarMobile/project.yml b/CodexBarMobile/project.yml index 0dee15d5..7c926c6f 100644 --- a/CodexBarMobile/project.yml +++ b/CodexBarMobile/project.yml @@ -34,8 +34,8 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.sync GENERATE_INFOPLIST_FILE: true - MARKETING_VERSION: "1.10.0" - CURRENT_PROJECT_VERSION: "147" + MARKETING_VERSION: "1.11.0" + CURRENT_PROJECT_VERSION: "149" CodexBarMobilePushExtension: type: app-extension @@ -55,8 +55,8 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.mobile.pushextension - MARKETING_VERSION: "1.10.0" - CURRENT_PROJECT_VERSION: "147" + MARKETING_VERSION: "1.11.0" + CURRENT_PROJECT_VERSION: "149" DEVELOPMENT_TEAM: 3TUERHN53E INFOPLIST_FILE: CodexBarMobilePushExtension/Info.plist CODE_SIGN_ENTITLEMENTS: CodexBarMobilePushExtension/PushExtension.entitlements @@ -75,8 +75,8 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.mobile - MARKETING_VERSION: "1.10.0" - CURRENT_PROJECT_VERSION: "147" + MARKETING_VERSION: "1.11.0" + CURRENT_PROJECT_VERSION: "149" DEVELOPMENT_TEAM: 3TUERHN53E INFOPLIST_FILE: CodexBarMobile/Info.plist CODE_SIGN_ENTITLEMENTS: CodexBarMobile/CodexBarMobile.entitlements diff --git a/Scripts/sign-and-notarize.sh b/Scripts/sign-and-notarize.sh index 47a40456..bdc96618 100755 --- a/Scripts/sign-and-notarize.sh +++ b/Scripts/sign-and-notarize.sh @@ -36,16 +36,27 @@ if [[ $(printf "%s\n" "$key_lines" | wc -l) -ne 1 ]]; then exit 1 fi +# Notarization API key + zip live in a private per-run temp dir (upstream +# #1228), not predictable /tmp paths. Fork keeps dual _FILE/_P8 support and +# its own mobile-suffixed ZIP_NAME / DSYM_ZIP (defined near the top), so we do +# NOT use upstream's codexbar_app_zip_name (which drops the -mobile.X suffix +# that release.sh / make_appcast expect). +NOTARIZATION_TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-notarize.XXXXXX") +chmod 700 "$NOTARIZATION_TEMP_DIR" +API_KEY_PATH="$NOTARIZATION_TEMP_DIR/codexbar-api-key.p8" +NOTARIZATION_ZIP="$NOTARIZATION_TEMP_DIR/${APP_NAME}Notarize.zip" +trap 'rm -rf "$NOTARIZATION_TEMP_DIR" "$RELEASE_STAGE_DIR"' EXIT + if [[ -n "${APP_STORE_CONNECT_API_KEY_FILE:-}" ]]; then if [[ ! -f "$APP_STORE_CONNECT_API_KEY_FILE" ]]; then echo "App Store Connect API key file not found: $APP_STORE_CONNECT_API_KEY_FILE" >&2 exit 1 fi - cp "$APP_STORE_CONNECT_API_KEY_FILE" /tmp/codexbar-api-key.p8 + ( umask 077; cp "$APP_STORE_CONNECT_API_KEY_FILE" "$API_KEY_PATH" ) else - echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/codexbar-api-key.p8 + ( umask 077; printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > "$API_KEY_PATH" ) fi -trap 'rm -f /tmp/codexbar-api-key.p8 /tmp/${APP_NAME}Notarize.zip; rm -rf "$RELEASE_STAGE_DIR"' EXIT +chmod 600 "$API_KEY_PATH" # Allow building a universal binary if ARCHES is provided; default to universal (arm64 + x86_64). ARCHES_VALUE=${ARCHES:-"arm64 x86_64"} @@ -82,11 +93,11 @@ codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \ "$APP_BUNDLE" DITTO_BIN=${DITTO_BIN:-/usr/bin/ditto} -"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "/tmp/${APP_NAME}Notarize.zip" +"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$NOTARIZATION_ZIP" echo "Submitting for notarization" -xcrun notarytool submit "/tmp/${APP_NAME}Notarize.zip" \ - --key /tmp/codexbar-api-key.p8 \ +xcrun notarytool submit "$NOTARIZATION_ZIP" \ + --key "$API_KEY_PATH" \ --key-id "$APP_STORE_CONNECT_KEY_ID" \ --issuer "$APP_STORE_CONNECT_ISSUER_ID" \ --wait diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 3cba8832..2aa0d327 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -48,7 +48,7 @@ struct CodexBarApp: App { configureUsageFormatterLocalizationProvider() let managedCodexAccountCoordinator = ManagedCodexAccountCoordinator() managedCodexAccountCoordinator.onManagedAccountsDidChange = { - _ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() + _ = settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange() } _ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() let fetcher = UsageFetcher() diff --git a/Sources/CodexBar/MemoryPressureRelief.swift b/Sources/CodexBar/MemoryPressureRelief.swift new file mode 100644 index 00000000..7a2162e3 --- /dev/null +++ b/Sources/CodexBar/MemoryPressureRelief.swift @@ -0,0 +1,7 @@ +import Darwin + +enum MemoryPressureRelief { + static func releaseFreeMallocPages() { + _ = malloc_zone_pressure_relief(nil, 0) + } +} diff --git a/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift new file mode 100644 index 00000000..473fabd7 --- /dev/null +++ b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift @@ -0,0 +1,61 @@ +import AppKit + +@MainActor +enum MenuBarStatusItemPlacementPreflight { + static let preferredPositionPrefix = "NSStatusItem Preferred Position " + static let suspiciousPreferredPositionPadding: Double = 512 + + static func preferredPositionKey(autosaveName: String) -> String { + "\(self.preferredPositionPrefix)\(autosaveName)" + } + + @discardableResult + static func prepare( + defaults: UserDefaults, + autosaveName: String, + legacyDefaultItemIndex: Int? = nil, + maximumPreferredPosition: Double? = currentMaximumPreferredPosition()) + -> Bool + { + let key = self.preferredPositionKey(autosaveName: autosaveName) + var repaired = self.clearPreferredPositionIfNeeded( + defaults: defaults, + key: key, + maximumPreferredPosition: maximumPreferredPosition) + if let legacyDefaultItemIndex { + let legacyKey = self.preferredPositionKey(autosaveName: "Item-\(legacyDefaultItemIndex)") + repaired = self.clearPreferredPositionIfNeeded( + defaults: defaults, + key: legacyKey, + maximumPreferredPosition: maximumPreferredPosition) || repaired + } + return repaired + } + + static func shouldClearPreferredPosition(_ value: Any, maximumPreferredPosition: Double?) -> Bool { + guard let number = value as? NSNumber else { return true } + let position = number.doubleValue + if position <= 0 { + return true + } + guard let maximumPreferredPosition else { return false } + return position > maximumPreferredPosition + self.suspiciousPreferredPositionPadding + } + + private static func clearPreferredPositionIfNeeded( + defaults: UserDefaults, + key: String, + maximumPreferredPosition: Double?) + -> Bool + { + guard let value = defaults.object(forKey: key), + self.shouldClearPreferredPosition(value, maximumPreferredPosition: maximumPreferredPosition) + else { return false } + defaults.removeObject(forKey: key) + return true + } + + private static func currentMaximumPreferredPosition() -> Double? { + NSScreen.screens.map { Double($0.frame.maxX) }.max() + } +} diff --git a/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift b/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift new file mode 100644 index 00000000..c479c859 --- /dev/null +++ b/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift @@ -0,0 +1,100 @@ +import AppKit +import CoreGraphics +import Foundation + +struct MenuBarStatusItemWindowSnapshot: Equatable, CustomStringConvertible { + let name: String + let ownerName: String + let bounds: CGRect + let isOnscreen: Bool + let displayBounds: CGRect? + + var isWithinDisplayBounds: Bool { + guard let displayBounds else { return false } + return displayBounds.contains(self.bounds) + } + + var description: String { + let display = self.displayBounds.map { + "display=\(Int($0.minX)),\(Int($0.minY)) \(Int($0.width))x\(Int($0.height))" + } ?? "display=nil" + return "name=\(self.name),owner=\(self.ownerName),x=\(Int(self.bounds.minX))," + + "w=\(Int(self.bounds.width)),onscreen=\(self.isOnscreen)," + + "withinDisplay=\(self.isWithinDisplayBounds),\(display)" + } +} + +enum MenuBarStatusItemWindowProbe { + static func snapshots(matching names: Set) -> [MenuBarStatusItemWindowSnapshot] { + self.snapshots( + matching: names, + windowInfo: self.windowInfo(), + displayBounds: NSScreen.screens.map(\.frame)) + } + + static func snapshots( + matching names: Set, + windowInfo: [[String: Any]], + displayBounds: [CGRect]) + -> [MenuBarStatusItemWindowSnapshot] + { + guard !names.isEmpty else { return [] } + return windowInfo.compactMap { record in + self.snapshot(record: record, matching: names, displayBounds: displayBounds) + } + } + + private static func windowInfo() -> [[String: Any]] { + guard let windows = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] else { + return [] + } + return windows + } + + private static func snapshot( + record: [String: Any], + matching names: Set, + displayBounds: [CGRect]) + -> MenuBarStatusItemWindowSnapshot? + { + guard let name = record[kCGWindowName as String] as? String, + names.contains(name), + let bounds = self.bounds(record[kCGWindowBounds as String]) + else { return nil } + let ownerName = record[kCGWindowOwnerName as String] as? String ?? "unknown" + let isOnscreen = (record[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue + ?? record[kCGWindowIsOnscreen as String] as? Bool + ?? false + return MenuBarStatusItemWindowSnapshot( + name: name, + ownerName: ownerName, + bounds: bounds, + isOnscreen: isOnscreen, + displayBounds: displayBounds.first { $0.intersects(bounds) }) + } + + private static func bounds(_ value: Any?) -> CGRect? { + guard let dictionary = value as? [String: Any], + let x = self.double(dictionary["X"]), + let y = self.double(dictionary["Y"]), + let width = self.double(dictionary["Width"]), + let height = self.double(dictionary["Height"]) + else { return nil } + return CGRect(x: x, y: y, width: width, height: height) + } + + private static func double(_ value: Any?) -> Double? { + switch value { + case let number as NSNumber: + number.doubleValue + case let double as Double: + double + case let int as Int: + Double(int) + case let cgFloat as CGFloat: + Double(cgFloat) + default: + nil + } + } +} diff --git a/Sources/CodexBar/MenuBarVisibilityWatcher.swift b/Sources/CodexBar/MenuBarVisibilityWatcher.swift index ec4055e2..36d2f383 100644 --- a/Sources/CodexBar/MenuBarVisibilityWatcher.swift +++ b/Sources/CodexBar/MenuBarVisibilityWatcher.swift @@ -203,7 +203,10 @@ extension StatusItemController { self.menuLogger.error( "Status item failed to materialize; recreating status items", - metadata: ["snapshots": snapshots.map(\.description).joined(separator: " | ")]) + metadata: [ + "snapshots": snapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), + ]) self.recreateStatusItemsForVisibilityRecovery() let recoveredSnapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) @@ -220,7 +223,10 @@ extension StatusItemController { self.menuLogger.error( "Status item still failed to materialize after recreation", - metadata: ["snapshots": recoveredSnapshots.map(\.description).joined(separator: " | ")]) + metadata: [ + "snapshots": recoveredSnapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), + ]) guard #available(macOS 26.0, *), MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults, now: now) else { @@ -272,6 +278,7 @@ extension StatusItemController { "currentScreenCount": "\(settledCurrentScreenCount)", "capturedScreenCount": "\(currentScreenCount)", "snapshots": snapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), ]) self.recreateStatusItemsForVisibilityRecovery() self.schedulePostScreenChangeRecoveryVerification(attempt: 1) @@ -322,6 +329,7 @@ extension StatusItemController { metadata: [ "attempt": "\(attempt)", "snapshots": snapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), ]) self.recreateStatusItemsForVisibilityRecovery() // No further async retries: a menu bar manager may park the newly recreated item in a state @@ -331,7 +339,10 @@ extension StatusItemController { guard MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot(finalSnapshots) else { return } self.menuLogger.error( "Status item still blocked after display-change recovery recreation", - metadata: ["snapshots": finalSnapshots.map(\.description).joined(separator: " | ")]) + metadata: [ + "snapshots": finalSnapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), + ]) guard #available(macOS 26.0, *), MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults) else { return } @@ -341,4 +352,13 @@ extension StatusItemController { private var startupVisibilityStatusItems: [NSStatusItem] { [self.statusItem] + Array(self.statusItems.values) } + + private func statusItemWindowDiagnosticsDescription() -> String { + let names = Set(self.startupVisibilityStatusItems.compactMap { item in + item.autosaveName.isEmpty ? nil : item.autosaveName + }) + let snapshots = MenuBarStatusItemWindowProbe.snapshots(matching: names) + guard !snapshots.isEmpty else { return "none" } + return snapshots.map(\.description).joined(separator: " | ") + } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 3c16bb85..7601233d 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -2,6 +2,15 @@ import AppKit import CodexBarCore import SwiftUI +enum UsageMenuCardLayout { + static let horizontalPadding: CGFloat = 20 + static let headerOnlyVerticalPadding: CGFloat = 7 + static let sectionTopPadding: CGFloat = 6 + static let sectionBottomPadding: CGFloat = 6 + static let headerLineSpacing: CGFloat = 4 + static let headerColumnSpacing: CGFloat = 12 +} + /// SwiftUI card used inside the NSMenu to mirror Apple's rich menu panels. struct UsageMenuCardView: View { struct Model { @@ -220,9 +229,17 @@ struct UsageMenuCardView: View { .padding(.bottom, self.model.creditsText == nil ? 6 : 0) } } - .padding(.horizontal, 16) - .padding(.top, 2) - .padding(.bottom, 2) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding( + .top, + self.hasDetails + ? UsageMenuCardLayout.sectionTopPadding + : UsageMenuCardLayout.headerOnlyVerticalPadding) + .padding( + .bottom, + self.hasDetails + ? UsageMenuCardLayout.sectionBottomPadding + : UsageMenuCardLayout.headerOnlyVerticalPadding) .frame(width: self.width, alignment: .leading) } @@ -238,8 +255,8 @@ private struct UsageMenuCardHeaderView: View { @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { - VStack(alignment: .leading, spacing: 3) { - HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: UsageMenuCardLayout.headerLineSpacing) { + HStack(alignment: .firstTextBaseline, spacing: UsageMenuCardLayout.headerColumnSpacing) { Text(self.model.providerName).font(.headline) .fontWeight(.semibold) .lineLimit(1).truncationMode(.tail).layoutPriority(1) @@ -249,7 +266,7 @@ private struct UsageMenuCardHeaderView: View { .lineLimit(1).truncationMode(.middle) } let subtitleAlignment: VerticalAlignment = self.model.subtitleStyle == .error ? .top : .firstTextBaseline - HStack(alignment: subtitleAlignment) { + HStack(alignment: subtitleAlignment, spacing: UsageMenuCardLayout.headerColumnSpacing) { Text(self.model.subtitleText) .font(.footnote) .foregroundStyle(self.subtitleColor) @@ -465,11 +482,20 @@ struct UsageMenuCardHeaderSectionView: View { Divider() } } - .padding(.horizontal, 16) - .padding(.top, 2) - .padding(.bottom, self.model.subtitleStyle == .error ? 2 : 0) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding(.top, UsageMenuCardLayout.headerOnlyVerticalPadding) + .padding(.bottom, self.headerBottomPadding) .frame(width: self.width, alignment: .leading) } + + private var headerBottomPadding: CGFloat { + if self.model.subtitleStyle == .error { + return UsageMenuCardLayout.sectionBottomPadding + } + return self.showDivider + ? UsageMenuCardLayout.sectionBottomPadding + : UsageMenuCardLayout.headerOnlyVerticalPadding + } } struct UsageMenuCardUsageSectionView: View { @@ -508,7 +534,7 @@ struct UsageMenuCardUsageSectionView: View { Divider() } } - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, 10) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) @@ -535,7 +561,7 @@ struct UsageMenuCardCreditsSectionView: View { Divider() } } - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) @@ -641,7 +667,7 @@ struct UsageMenuCardCostSectionView: View { } } } - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) @@ -662,7 +688,7 @@ struct UsageMenuCardExtraUsageSectionView: View { ProviderCostContent( section: providerCost, progressColor: self.model.progressColor) - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index e3f21cf0..59e21ffb 100644 --- a/Sources/CodexBar/PreferencesProviderSidebarView.swift +++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift @@ -5,43 +5,58 @@ import UniformTypeIdentifiers @MainActor struct ProviderSidebarListView: View { let providers: [UsageProvider] + let orderedProviders: [UsageProvider] @Bindable var store: UsageStore let isEnabled: (UsageProvider) -> Binding let subtitle: (UsageProvider) -> String + @Binding var searchText: String @Binding var selection: UsageProvider? let moveProviders: (IndexSet, Int) -> Void @State private var draggingProvider: UsageProvider? var body: some View { - ScrollView { - VStack(spacing: 0) { - ForEach(self.providers, id: \.self) { provider in - ProviderSidebarRowView( - provider: provider, - store: self.store, - isEnabled: self.isEnabled(provider), - subtitle: self.subtitle(provider), - draggingProvider: self.$draggingProvider) - .padding(.horizontal, 8) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill( - self.selection == provider - ? Color(nsColor: .selectedContentBackgroundColor) - : Color.clear) - .padding(.horizontal, 4)) - .contentShape(Rectangle()) - .onTapGesture { self.selection = provider } - .onDrop( - of: [UTType.plainText], - delegate: ProviderSidebarDropDelegate( - item: provider, - providers: self.providers, - dragging: self.$draggingProvider, - moveProviders: self.moveProviders)) + VStack(spacing: 8) { + ProviderSidebarSearchField(searchText: self.$searchText) + .padding(.horizontal, 8) + .padding(.top, 8) + + ScrollView { + VStack(spacing: 0) { + if self.providers.isEmpty { + Text(L("No matching providers")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 80) + } + + ForEach(self.providers, id: \.self) { provider in + ProviderSidebarRowView( + provider: provider, + store: self.store, + isEnabled: self.isEnabled(provider), + subtitle: self.subtitle(provider), + draggingProvider: self.$draggingProvider) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + self.selection == provider + ? Color(nsColor: .selectedContentBackgroundColor) + : Color.clear) + .padding(.horizontal, 4)) + .contentShape(Rectangle()) + .onTapGesture { self.selection = provider } + .onDrop( + of: [UTType.plainText], + delegate: ProviderSidebarDropDelegate( + item: provider, + providers: self.orderedProviders, + dragging: self.$draggingProvider, + moveProviders: self.moveProviders)) + } } + .padding(.vertical, 4) } - .padding(.vertical, 4) } .background( RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous) @@ -54,6 +69,41 @@ struct ProviderSidebarListView: View { } } +private struct ProviderSidebarSearchField: View { + @Binding var searchText: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + + TextField(L("Search providers"), text: self.$searchText) + .textFieldStyle(.plain) + + if !self.searchText.isEmpty { + Button { + self.searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .accessibilityLabel(L("Clear")) + } + .buttonStyle(.plain) + } + } + .font(.callout) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor))) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1)) + } +} + @MainActor private struct ProviderSidebarRowView: View { let provider: UsageProvider diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 22423d90..55d11441 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -16,12 +16,20 @@ struct ProvidersPane: View { @State private var activeConfirmation: ProviderSettingsConfirmationState? @State private var codexAccountsNotice: CodexAccountsSectionNotice? @State private var isAuthenticatingLiveCodexAccount = false + @State private var providerSearchText = "" @State private var selectedProvider: UsageProvider? private var providers: [UsageProvider] { self.settings.orderedProviders() } + private var filteredProviders: [UsageProvider] { + Self.filteredProviders( + self.providers, + query: self.providerSearchText, + displayName: { provider in self.store.metadata(for: provider).displayName }) + } + init( settings: SettingsStore, store: UsageStore, @@ -45,16 +53,18 @@ struct ProvidersPane: View { var body: some View { HStack(alignment: .top, spacing: 16) { ProviderSidebarListView( - providers: self.providers, + providers: self.filteredProviders, + orderedProviders: self.providers, store: self.store, isEnabled: { provider in self.binding(for: provider) }, subtitle: { provider in self.providerSubtitle(provider) }, + searchText: self.$providerSearchText, selection: self.$selectedProvider, moveProviders: { fromOffsets, toOffset in self.settings.moveProvider(fromOffsets: fromOffsets, toOffset: toOffset) }) - if let provider = self.selectedProvider ?? self.providers.first { + if let provider = self.selectedVisibleProvider { ProviderDetailView( provider: provider, store: self.store, @@ -116,6 +126,9 @@ struct ProvidersPane: View { .onChange(of: self.providers) { _, _ in self.ensureSelection() } + .onChange(of: self.providerSearchText) { _, _ in + self.ensureSelection() + } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in self.runSettingsDidBecomeActiveHooks() } @@ -142,15 +155,38 @@ struct ProvidersPane: View { }) } + private var selectedVisibleProvider: UsageProvider? { + let filteredProviders = self.filteredProviders + if let selected = self.selectedProvider, filteredProviders.contains(selected) { + return selected + } + return filteredProviders.first + } + + static func filteredProviders( + _ providers: [UsageProvider], + query: String, + displayName: (UsageProvider) -> String) -> [UsageProvider] + { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { return providers } + + return providers.filter { provider in + displayName(provider).localizedCaseInsensitiveContains(trimmedQuery) + || provider.rawValue.localizedCaseInsensitiveContains(trimmedQuery) + } + } + private func ensureSelection() { - guard !self.providers.isEmpty else { + let filteredProviders = self.filteredProviders + guard !filteredProviders.isEmpty else { self.selectedProvider = nil return } - if let selected = self.selectedProvider, self.providers.contains(selected) { + if let selected = self.selectedProvider, filteredProviders.contains(selected) { return } - self.selectedProvider = self.providers.first + self.selectedProvider = filteredProviders.first } private func triggerRefresh(for provider: UsageProvider) { diff --git a/Sources/CodexBar/ProviderBrandIcon.swift b/Sources/CodexBar/ProviderBrandIcon.swift index cec160ad..844e4677 100644 --- a/Sources/CodexBar/ProviderBrandIcon.swift +++ b/Sources/CodexBar/ProviderBrandIcon.swift @@ -1,11 +1,16 @@ import AppKit import CodexBarCore +@MainActor enum ProviderBrandIcon { private static let size = NSSize(width: 16, height: 16) + private static var cache: [UsageProvider: NSImage] = [:] /// Lazy-loaded resource bundle for provider icons. private static let resourceBundle: Bundle? = { + guard Bundle.main.bundleURL.pathExtension == "app" else { + return Bundle.module + } // SwiftPM creates a CodexBar_CodexBar.bundle for resources in the CodexBar target. if let bundleURL = Bundle.main.url(forResource: "CodexBar_CodexBar", withExtension: "bundle"), let bundle = Bundle(url: bundleURL) @@ -17,6 +22,10 @@ enum ProviderBrandIcon { }() static func image(for provider: UsageProvider) -> NSImage? { + if let cached = self.cache[provider] { + return cached + } + let baseName = ProviderDescriptorRegistry.descriptor(for: provider).branding.iconResourceName guard let bundle = self.resourceBundle else { return nil @@ -29,6 +38,11 @@ enum ProviderBrandIcon { image.size = self.size image.isTemplate = true + self.cache[provider] = image return image } + + static func resetCacheForTesting() { + self.cache.removeAll() + } } diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift index b2593c54..0a6bb90d 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift @@ -92,6 +92,7 @@ final class AugmentProviderRuntime: ProviderRuntime { private func forceRefresh(context: ProviderRuntimeContext) async { #if os(macOS) context.store.augmentLogger.info("Augment force refresh requested") + CookieHeaderCache.clear(provider: .augment) guard let keepalive = self.keepalive else { context.store.augmentLogger.warning("Augment keepalive not running; starting") self.startKeepalive(context: context) @@ -105,8 +106,6 @@ final class AugmentProviderRuntime: ProviderRuntime { } await keepalive.forceRefresh() - context.store.augmentLogger.info("Refreshing Augment usage after session refresh") - await context.store.refreshProvider(.augment) #endif } } diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 8bfb331f..592da489 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -128,6 +128,7 @@ extension SettingsStore { self.codexPersistedActiveSource } set { + self.invalidateCodexAccountReconciliationSnapshotCache() self.updateProviderConfig(provider: .codex) { entry in entry.codexActiveSource = newValue } @@ -150,6 +151,12 @@ extension SettingsStore { return true } + @discardableResult + func refreshCodexAccountReconciliationAfterManagedAccountsDidChange() -> Bool { + self.invalidateCodexAccountReconciliationSnapshotCache() + return self.persistResolvedCodexActiveSourceCorrectionIfNeeded() + } + var codexCookieHeader: String { get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedCookieHeader ?? "" } set { @@ -180,6 +187,19 @@ extension SettingsStore { } extension SettingsStore { + private static var codexAccountReconciliationSnapshotCacheInterval: TimeInterval { + #if DEBUG + if let codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting { + return codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting + } + #endif + return self.isRunningTests ? 0 : self.productionCodexAccountReconciliationSnapshotCacheInterval + } + + func invalidateCodexAccountReconciliationSnapshotCache() { + self.cachedCodexAccountReconciliationSnapshot = nil + } + var codexAccountReconciliationSnapshot: CodexAccountReconciliationSnapshot { self.codexAccountReconciliationSnapshot(activeSourceOverride: nil) } @@ -187,9 +207,25 @@ extension SettingsStore { func codexAccountReconciliationSnapshot( activeSourceOverride: CodexActiveSource?) -> CodexAccountReconciliationSnapshot { - self.codexAccountReconciler( - activeSource: activeSourceOverride ?? self.codexPersistedActiveSource) - .loadSnapshot() + let activeSource = activeSourceOverride ?? self.codexPersistedActiveSource + let cacheInterval = Self.codexAccountReconciliationSnapshotCacheInterval + let now = Date() + if cacheInterval > 0, + let cached = self.cachedCodexAccountReconciliationSnapshot, + cached.activeSource == activeSource, + now.timeIntervalSince(cached.loadedAt) < cacheInterval + { + return cached.snapshot + } + + let snapshot = self.codexAccountReconciler(activeSource: activeSource).loadSnapshot() + if cacheInterval > 0 { + self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: activeSource, + loadedAt: now, + snapshot: snapshot) + } + return snapshot } var codexVisibleAccountProjection: CodexVisibleAccountProjection { @@ -203,6 +239,7 @@ extension SettingsStore { @discardableResult func selectCodexVisibleAccount(id: String) -> Bool { guard let source = self.codexSource(forVisibleAccountID: id) else { return false } + self.invalidateCodexAccountReconciliationSnapshotCache() self.codexActiveSource = source return true } @@ -212,6 +249,7 @@ extension SettingsStore { return } // An open menu can preserve a previously rendered account row while the live projection is briefly incomplete. + self.invalidateCodexAccountReconciliationSnapshotCache() self.codexActiveSource = account.selectionSource } @@ -224,6 +262,7 @@ extension SettingsStore { return } + self.invalidateCodexAccountReconciliationSnapshotCache() self.codexActiveSource = .managedAccount(id: account.id) _ = self.persistResolvedCodexActiveSourceCorrectionIfNeeded() } @@ -469,32 +508,50 @@ private struct CodexManagedRemoteHomeTestingSystemObserver: CodexSystemAccountOb extension SettingsStore { var _test_activeManagedCodexRemoteHomePath: String? { get { CodexManagedRemoteHomeTestingOverride.homePath(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self) + } } var _test_activeManagedCodexAccount: ManagedCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.account(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setAccount(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setAccount(newValue, for: self) + } } var _test_unreadableManagedCodexAccountStore: Bool { get { CodexManagedRemoteHomeTestingOverride.isUnreadable(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setUnreadable(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setUnreadable(newValue, for: self) + } } var _test_managedCodexAccountStoreURL: URL? { get { CodexManagedRemoteHomeTestingOverride.managedStoreURL(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setManagedStoreURL(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setManagedStoreURL(newValue, for: self) + } } var _test_liveSystemCodexAccount: ObservedSystemCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.liveSystemAccount(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setLiveSystemAccount(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setLiveSystemAccount(newValue, for: self) + } } var _test_codexReconciliationEnvironment: [String: String]? { get { CodexManagedRemoteHomeTestingOverride.reconciliationEnvironment(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setReconciliationEnvironment(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setReconciliationEnvironment(newValue, for: self) + } } } #endif diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 9fc46ba2..6c7a760a 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -913,3 +913,6 @@ "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\no enganxa el valor de __Secure-next-auth.session-token"; "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\no enganxa el valor del token kimi-auth"; "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\no enganxa només el valor de session_id"; +"Clear" = "Esborra"; +"No matching providers" = "No hi ha proveïdors coincidents"; +"Search providers" = "Cerca proveïdors"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 5a3e6db3..be669770 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1081,3 +1081,6 @@ "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nor paste the __Secure-next-auth.session-token value"; "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nor paste the kimi-auth token value"; "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nor paste just the session_id value"; +"Clear" = "Clear"; +"No matching providers" = "No matching providers"; +"Search providers" = "Search providers"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 958bd89d..293183e7 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -913,3 +913,6 @@ "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\no pega el valor de __Secure-next-auth.session-token"; "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\no pega el valor del token kimi-auth"; "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\no pega solo el valor de session_id"; +"Clear" = "Borrar"; +"No matching providers" = "No hay proveedores coincidentes"; +"Search providers" = "Buscar proveedores"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 5210e0c6..bf843334 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1056,3 +1056,6 @@ "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nou cole o valor de __Secure-next-auth.session-token"; "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nou cole o valor do token kimi-auth"; "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nou cole apenas o valor de session_id"; +"Clear" = "Limpar"; +"No matching providers" = "Nenhum provedor correspondente"; +"Search providers" = "Buscar provedores"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index ecd33813..429d98e0 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1055,3 +1055,6 @@ "CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Synthetic-API-nyckel så att användning kan hämtas. Klicka på OK för att fortsätta."; "CodexBar could not update managed account storage." = "CodexBar kunde inte uppdatera hanterad kontolagring."; "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Augment-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; +"Clear" = "Rensa"; +"No matching providers" = "Inga matchande leverantörer"; +"Search providers" = "Sök leverantörer"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index f956f0fd..191f3fea 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1057,3 +1057,6 @@ "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\n或粘贴 __Secure-next-auth.session-token 值"; "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\n或粘贴 kimi-auth token 值"; "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\n或只粘贴 session_id 值"; +"Clear" = "清除"; +"No matching providers" = "没有匹配的提供商"; +"Search providers" = "搜索提供商"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index f87b1583..46034fcf 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -926,3 +926,6 @@ "Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\n或貼上 __Secure-next-auth.session-token 值"; "Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\n或貼上 kimi-auth token 值"; "session_id=...\n\nor paste just the session_id value" = "session_id=...\n\n或只貼上 session_id 值"; +"Clear" = "清除"; +"No matching providers" = "沒有相符的提供者"; +"Search providers" = "搜尋提供者"; diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 01121322..e04b084a 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -108,11 +108,18 @@ enum MultiAccountMenuLayout: String, CaseIterable, Identifiable { } } +struct CachedCodexAccountReconciliationSnapshot { + let activeSource: CodexActiveSource + let loadedAt: Date + let snapshot: CodexAccountReconciliationSnapshot +} + @MainActor @Observable final class SettingsStore { static let sharedDefaults = AppGroupSupport.sharedDefaults() static let mergedOverviewProviderLimit = 3 + static let productionCodexAccountReconciliationSnapshotCacheInterval: TimeInterval = 2 static let isRunningTests: Bool = { let env = ProcessInfo.processInfo.environment if env["XCTestConfigurationFilePath"] != nil { return true } @@ -121,12 +128,18 @@ final class SettingsStore { return NSClassFromString("XCTestCase") != nil }() + #if DEBUG + static var codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting: TimeInterval? + #endif + @ObservationIgnored let userDefaults: UserDefaults @ObservationIgnored let configStore: CodexBarConfigStore @ObservationIgnored var config: CodexBarConfig @ObservationIgnored var configPersistTask: Task? @ObservationIgnored var configLoading = false @ObservationIgnored var tokenAccountsLoaded = false + @ObservationIgnored var cachedCodexAccountReconciliationSnapshot: + CachedCodexAccountReconciliationSnapshot? var defaultsState: SettingsDefaultsState var configRevision: Int = 0 var providerOrder: [UsageProvider] = [] diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index fbb4ebcd..6a3c7b41 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -12,16 +12,31 @@ enum LoginNotificationLogic { extension StatusItemController: StatusItemMenuPersistentActionDelegate { // MARK: - Actions reachable from menus - func refreshStore(forceTokenUsage: Bool, refreshOpenMenusWhenComplete: Bool = true) { + func refreshStore( + forceTokenUsage: Bool, + refreshOpenMenusWhenComplete: Bool = true, + interaction: ProviderInteraction = .userInitiated) + { Task { - await ProviderInteractionContext.$current.withValue(.userInitiated) { - await self.store.refresh(forceTokenUsage: forceTokenUsage) - self.store.scheduleStorageFootprintRefreshForOverview(force: true) - if refreshOpenMenusWhenComplete { - self.refreshOpenMenusAfterExplicitStoreAction() - } else { - self.invalidateMenus() - } + await self.performStoreRefresh( + forceTokenUsage: forceTokenUsage, + refreshOpenMenusWhenComplete: refreshOpenMenusWhenComplete, + interaction: interaction) + } + } + + func performStoreRefresh( + forceTokenUsage: Bool, + refreshOpenMenusWhenComplete: Bool, + interaction: ProviderInteraction) async + { + await ProviderInteractionContext.$current.withValue(interaction) { + await self.store.refresh(forceTokenUsage: forceTokenUsage) + self.store.scheduleStorageFootprintRefreshForOverview(force: true) + if refreshOpenMenusWhenComplete { + self.refreshOpenMenusAfterExplicitStoreAction() + } else { + self.invalidateMenus() } } } diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 510e3f07..a2181b83 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -10,7 +10,6 @@ extension StatusItemController { static let loadingAnimationPhaseIncrement: Double = 2.7 / StatusItemController.loadingAnimationFPS private static let loadingAnimationMaxContinuousDuration: TimeInterval = 30.0 - func needsMenuBarIconAnimation() -> Bool { if self.shouldMergeIcons { let primaryProvider = self.primaryProviderForUnifiedIcon() @@ -338,6 +337,9 @@ extension StatusItemController { "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { + // AppKit can lose button title/image-position state independently of the cached render signature. + // Keep the cheap title path self-healing even when the icon image itself can be skipped. + self.setButtonTitle(displayText, for: button) self.noteIconPerfRender(skipped: true) return true } @@ -915,7 +917,7 @@ extension StatusItemController { { return selected } - for provider in UsageProvider.allCases { + for provider in self.store.enabledProviders() { if self.store.isEnabled(provider), self.store.snapshot(for: provider) != nil { return provider } diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 65bad39d..1f080abe 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -91,11 +91,82 @@ extension StatusItemController { } guard !didHydrate else { return } + self.appendHostedSubviewUnavailableItem(to: menu, chartID: chartID, providerRawValue: placeholder.toolTip) + } + + func refreshHostedSubviewMenu(_ menu: NSMenu) { + let width = self.renderedMenuWidth(for: menu) + guard let identity = self.hostedSubviewIdentity(for: menu) else { + self.refreshHostedSubviewHeights(in: menu) + return + } + menu.removeAllItems() + let didHydrate: Bool = switch identity.chartID { + case Self.usageBreakdownChartID: + self.appendUsageBreakdownChartItem(to: menu, width: width) + case Self.creditsHistoryChartID: + self.appendCreditsHistoryChartItem(to: menu, width: width) + case Self.costHistoryChartID: + if let provider = identity.provider { + self.appendCostHistoryChartItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.usageHistoryChartID: + if let provider = identity.provider { + self.appendUsageHistoryChartItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.storageBreakdownID: + if let provider = identity.provider { + self.appendStorageBreakdownItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.zaiHourlyUsageChartID: + if let provider = identity.provider { + self.appendZaiHourlyUsageChartItem(to: menu, provider: provider, width: width) + } else { + false + } + default: + false + } + + if didHydrate { + self.refreshHostedSubviewHeights(in: menu) + } else { + self.appendHostedSubviewUnavailableItem( + to: menu, + chartID: identity.chartID, + providerRawValue: identity.provider?.rawValue ?? identity.providerRawValue) + } + } + + private func hostedSubviewIdentity(for menu: NSMenu) + -> (chartID: String, provider: UsageProvider?, providerRawValue: String?)? { + for item in menu.items { + guard let chartID = item.representedObject as? String else { continue } + let providerRawValue = item.toolTip + return ( + chartID: chartID, + provider: providerRawValue.flatMap(UsageProvider.init(rawValue:)), + providerRawValue: providerRawValue) + } + return nil + } + + private func appendHostedSubviewUnavailableItem( + to menu: NSMenu, + chartID: String, + providerRawValue: String?) + { let unavailableItem = NSMenuItem(title: L("No data available"), action: nil, keyEquivalent: "") unavailableItem.isEnabled = false unavailableItem.representedObject = chartID - unavailableItem.toolTip = placeholder.toolTip + unavailableItem.toolTip = providerRawValue menu.addItem(unavailableItem) } @@ -167,6 +238,7 @@ extension StatusItemController { let chartItem = NSMenuItem() chartItem.isEnabled = true chartItem.representedObject = Self.costHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } @@ -188,6 +260,7 @@ extension StatusItemController { chartItem.view = hosting chartItem.isEnabled = true chartItem.representedObject = Self.costHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index f3926a93..225331c5 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -73,13 +73,25 @@ extension StatusItemController { } func menuWillOpen(_ menu: NSMenu) { + let menuOpenStartedAt = CACurrentMediaTime() + defer { + self.logMenuOperationDurationIfSlow( + "menuWillOpen", + startedAt: menuOpenStartedAt, + menu: menu, + provider: self.menuProvider(for: menu)) + } + + self.cancelDeferredMenuInteractionRefreshTask() + self.cancelClosedMenuRebuild(menu) + if self.isHostedSubviewMenu(menu) { self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) - if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { - self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") + if self.isMenuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { + self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "submenu open") } - if Self.menuRefreshEnabled { + if self.isMenuRefreshEnabled { // Intentionally skip open-menu tracking when refresh is disabled (tests). // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. self.openMenus[ObjectIdentifier(menu)] = menu @@ -107,13 +119,12 @@ extension StatusItemController { } } - let didRefresh = self.menuNeedsRefresh(menu) - if didRefresh { - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - // Heights are already set during populateMenu, no need to remeasure + if self.isMenuRefreshEnabled, (provider ?? self.lastMenuProvider) == .codex { + self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open") } - if Self.menuRefreshEnabled { + + self.refreshMenuForOpenIfNeeded(menu, provider: provider) + if self.isMenuRefreshEnabled { // Intentionally skip open-menu tracking when refresh is disabled (tests). // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. self.openMenus[ObjectIdentifier(menu)] = menu @@ -127,7 +138,7 @@ extension StatusItemController { let wasHostedSubviewMenu = self.isHostedSubviewMenu(menu) self.forgetClosedMenu(menu) if wasHostedSubviewMenu { - self.refreshOpenMenusIfNeeded() + self.refreshOpenMenusAfterHostedSubviewClose() } } @@ -138,6 +149,7 @@ extension StatusItemController { self.removeProviderSwitcherShortcutMonitor() } + self.cancelClosedMenuRebuild(menu) self.openMenus.removeValue(forKey: key) self.menuRefreshTasks.removeValue(forKey: key)?.cancel() self.openMenuRebuildTasks.removeValue(forKey: key)?.cancel() @@ -153,7 +165,11 @@ extension StatusItemController { if !isPersistentMenu { self.menuProviders.removeValue(forKey: key) self.menuVersions.removeValue(forKey: key) + } else if self.menuNeedsRefresh(menu) { + self.rebuildClosedMenuIfNeeded(menu) } + self.parentMenuRebuildsDeferredDuringTracking.remove(key) + self.scheduleDeferredMenuInteractionRefreshIfNeeded() } func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { @@ -174,6 +190,15 @@ extension StatusItemController { } func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { + let populateStartedAt = CACurrentMediaTime() + defer { + self.logMenuOperationDurationIfSlow( + "populateMenu", + startedAt: populateStartedAt, + menu: menu, + provider: provider) + } + let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 @@ -892,6 +917,7 @@ extension StatusItemController { textField.translatesAutoresizingMaskIntoConstraints = false container.addSubview(textField) + // macos-smell:disable MACOS005 NSLayoutConstraint.activate([ textField.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), textField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), @@ -1069,16 +1095,6 @@ extension StatusItemController { return .provider(self.resolvedMenuProvider(enabledProviders: enabledProviders) ?? .codex) } - func menuNeedsRefresh(_ menu: NSMenu) -> Bool { - let key = ObjectIdentifier(menu) - return self.menuVersions[key] != self.menuContentVersion - } - - func markMenuFresh(_ menu: NSMenu) { - let key = ObjectIdentifier(menu) - self.menuVersions[key] = self.menuContentVersion - } - func menuProvider(for menu: NSMenu) -> UsageProvider? { if self.shouldMergeIcons { return self.resolvedMenuProvider() @@ -1092,30 +1108,13 @@ extension StatusItemController { return self.store.enabledProvidersForDisplay().first ?? .codex } - func hasOpenHostedSubviewMenu() -> Bool { - self.openMenus.values.contains { self.isHostedSubviewMenu($0) } - } - - func refreshOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { - self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) - } - - func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { - guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } - guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return } - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - self.applyIcon(phase: nil) - #if DEBUG - self._test_openMenuRebuildObserver?(menu) - #endif - } - private func scheduleOpenMenuRefresh(for menu: NSMenu) { - // Kick off a refresh on open (non-forced) and re-check after a delay. - // NEVER block menu opening with network requests. - if !self.store.isRefreshing { - self.refreshStore(forceTokenUsage: false, refreshOpenMenusWhenComplete: false) + // Queue refresh work only when visible menu data is missing or stale. Here "stale" means the last + // provider fetch failed and needs a retry; periodic freshness is handled by the refresh timer. + // AppKit menu tracking is modal, so starting provider refreshes while it is active can make the menu + // feel frozen and can block keyboard focus from returning. + if self.menuNeedsDelayedRefreshRetry(for: menu) { + self.deferMenuInteractionRefreshIfNeeded() } let key = ObjectIdentifier(menu) self.menuRefreshTasks[key]?.cancel() @@ -1123,7 +1122,7 @@ extension StatusItemController { guard let self, let menu else { return } try? await Task.sleep(for: Self.menuOpenRefreshDelay) guard !Task.isCancelled else { return } - guard Self.menuRefreshEnabled else { return } + guard self.isMenuRefreshEnabled else { return } #if DEBUG self.onDelayedMenuRefreshAttemptForTesting?() #endif @@ -1134,7 +1133,7 @@ extension StatusItemController { let retryMissingSnapshotCount = retryProviders.count { self.store.snapshot(for: $0) == nil } let willRetryRefresh = retryStaleProviderCount > 0 || retryMissingSnapshotCount > 0 guard willRetryRefresh else { return } - self.refreshStore(forceTokenUsage: false, refreshOpenMenusWhenComplete: false) + self.deferMenuInteractionRefreshIfNeeded() } } diff --git a/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift new file mode 100644 index 00000000..d5d0ff1c --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift @@ -0,0 +1,124 @@ +import AppKit +import CodexBarCore +import QuartzCore + +extension StatusItemController { + private static let defaultDeferredMenuInteractionRefreshDelay: Duration = .milliseconds(250) + private static let slowMenuOperationThreshold: TimeInterval = 0.15 + + #if DEBUG + private static var deferredMenuInteractionRefreshDelayForTesting: Duration = .milliseconds(250) + + static func setDeferredMenuInteractionRefreshDelayForTesting(_ delay: Duration) { + self.deferredMenuInteractionRefreshDelayForTesting = delay + } + + static func resetDeferredMenuInteractionRefreshDelayForTesting() { + self.deferredMenuInteractionRefreshDelayForTesting = self.defaultDeferredMenuInteractionRefreshDelay + } + #endif + + private static var deferredMenuInteractionRefreshDelay: Duration { + #if DEBUG + deferredMenuInteractionRefreshDelayForTesting + #else + defaultDeferredMenuInteractionRefreshDelay + #endif + } + + func logMenuOperationDurationIfSlow( + _ operation: String, + startedAt: CFTimeInterval, + menu: NSMenu, + provider: UsageProvider?) + { + let elapsed = CACurrentMediaTime() - startedAt + guard elapsed >= Self.slowMenuOperationThreshold else { return } + self.menuLogger.warning( + "slow menu operation", + metadata: [ + "operation": operation, + "durationMs": String(format: "%.1f", elapsed * 1000), + "items": "\(menu.items.count)", + "provider": provider?.rawValue ?? "nil", + "openMenus": "\(self.openMenus.count)", + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) + } + + func deferMenuInteractionRefreshIfNeeded() { + guard !self.store.isRefreshing else { return } + self.deferredMenuInteractionRefreshPending = true + } + + func deferOpenAIDashboardRefreshUntilMenuCloses(reason: String) { + if let existingReason = self.deferredOpenAIDashboardRefreshReason { + self.deferredOpenAIDashboardRefreshReason = "\(existingReason), \(reason)" + } else { + self.deferredOpenAIDashboardRefreshReason = reason + } + } + + func cancelDeferredMenuInteractionRefreshTask() { + self.deferredMenuInteractionRefreshTask?.cancel() + self.deferredMenuInteractionRefreshTask = nil + } + + func scheduleDeferredMenuInteractionRefreshIfNeeded(delay: Duration? = nil) { + guard self.openMenus.isEmpty else { return } + guard self.deferredMenuInteractionRefreshPending || self.deferredOpenAIDashboardRefreshReason != nil else { + return + } + guard !self.hasPreparedForAppShutdown else { return } + + self.cancelDeferredMenuInteractionRefreshTask() + let delay = delay ?? Self.deferredMenuInteractionRefreshDelay + self.deferredMenuInteractionRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + guard let self, !Task.isCancelled else { return } + guard self.openMenus.isEmpty else { + self.deferredMenuInteractionRefreshTask = nil + return + } + let shouldRefreshStore = self.deferredMenuInteractionRefreshPending + let openAIDashboardRefreshReason = self.deferredOpenAIDashboardRefreshReason + guard shouldRefreshStore || openAIDashboardRefreshReason != nil else { + self.deferredMenuInteractionRefreshTask = nil + return + } + guard !self.hasPreparedForAppShutdown else { + self.deferredMenuInteractionRefreshTask = nil + return + } + guard !self.store.isRefreshing else { + self.deferredMenuInteractionRefreshTask = nil + self + .scheduleDeferredMenuInteractionRefreshIfNeeded(delay: Self + .defaultDeferredMenuInteractionRefreshDelay) + return + } + self.deferredMenuInteractionRefreshTask = nil + self.deferredMenuInteractionRefreshPending = false + self.deferredOpenAIDashboardRefreshReason = nil + #if DEBUG + self.onDeferredMenuInteractionRefreshForTesting?() + #endif + if shouldRefreshStore { + await self.performStoreRefresh( + forceTokenUsage: false, + refreshOpenMenusWhenComplete: false, + interaction: .background) + guard !Task.isCancelled else { return } + } + if let openAIDashboardRefreshReason { + guard self.openMenus.isEmpty else { + self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: openAIDashboardRefreshReason) + return + } + // Keep menu-originated automatic dashboard refreshes non-interactive: + // opening a menu is not consent to show macOS Keychain prompts. + self.store.requestOpenAIDashboardRefreshIfStale(reason: openAIDashboardRefreshReason) + } + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuLocalization.swift b/Sources/CodexBar/StatusItemController+MenuLocalization.swift index 52944810..e90f03de 100644 --- a/Sources/CodexBar/StatusItemController+MenuLocalization.swift +++ b/Sources/CodexBar/StatusItemController+MenuLocalization.swift @@ -4,6 +4,7 @@ extension StatusItemController { func menuLocalizationSignature() -> String { [ codexBarLocalizationSignature(), + self.settings.hidePersonalInfo ? "hide-personal-info" : "show-personal-info", L("Overview"), L("Cost"), ].joined(separator: "|") diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 594aa483..4742e30e 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -3,6 +3,94 @@ import CodexBarCore import QuartzCore extension StatusItemController { + func didMenuAdjunctReadinessChange() -> Bool { + let signature = self.menuAdjunctReadinessSignature() + defer { self.lastMenuAdjunctReadinessSignature = signature } + return signature != self.lastMenuAdjunctReadinessSignature + } + + func menuAdjunctReadinessSignature() -> String { + let dashboard = self.store.openAIDashboard + let dashboardUsageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: dashboard?.usageBreakdown ?? []) + var parts = [ + "costEnabled=\(self.settings.costUsageEnabled ? "1" : "0")", + "openAIAttached=\(self.store.openAIDashboardAttachmentAuthorized ? "1" : "0")", + "openAILogin=\(self.store.openAIDashboardRequiresLogin ? "1" : "0")", + "openAIUpdated=\(Self.millisecondsSinceEpoch(dashboard?.updatedAt))", + "openAIDaily=\(Self.dashboardBreakdownReadinessSignature(dashboard?.dailyBreakdown ?? []))", + "openAIUsage=\(Self.dashboardBreakdownReadinessSignature(dashboardUsageBreakdown))", + "credits=\(self.store.credits == nil ? "0" : "1")", + "planHistoryRevision=\(self.store.planUtilizationHistoryRevision)", + ] + + for provider in self.store.enabledProvidersForDisplay() { + let tokenSignature = self.tokenSnapshotReadinessSignature(for: provider) + let usageHistoryVisible = self.store.supportsPlanUtilizationHistory(for: provider) && + !self.store.shouldHidePlanUtilizationMenuItem(for: provider) + parts.append( + [ + provider.rawValue, + "token=\(tokenSignature)", + "usageHistory=\(usageHistoryVisible ? "1" : "0")", + ].joined(separator: ":")) + } + + return parts.joined(separator: "|") + } + + private static func dashboardBreakdownReadinessSignature( + _ breakdown: [OpenAIDashboardDailyBreakdown]) -> String + { + breakdown + .map { day in + let services = day.services + .map { "\($0.service)=\(Self.formatDoubleForSignature($0.creditsUsed))" } + .joined(separator: ",") + return [ + day.day, + Self.formatDoubleForSignature(day.totalCreditsUsed), + services, + ].joined(separator: ":") + } + .joined(separator: ";") + } + + private func tokenSnapshotReadinessSignature(for provider: UsageProvider) -> String { + guard let snapshot = self.store.tokenSnapshot(for: provider) else { return "none" } + let daily = snapshot.daily + .map { entry in + [ + entry.date, + "\(entry.totalTokens ?? -1)", + Self.formatOptionalDoubleForSignature(entry.costUSD), + ].joined(separator: ",") + } + .joined(separator: ";") + return [ + "sessionTokens=\(snapshot.sessionTokens ?? -1)", + "sessionCost=\(Self.formatOptionalDoubleForSignature(snapshot.sessionCostUSD))", + "lastTokens=\(snapshot.last30DaysTokens ?? -1)", + "lastCost=\(Self.formatOptionalDoubleForSignature(snapshot.last30DaysCostUSD))", + "updated=\(Int(snapshot.updatedAt.timeIntervalSince1970 * 1000))", + "daily=\(daily)", + ].joined(separator: ",") + } + + private static func millisecondsSinceEpoch(_ date: Date?) -> Int { + guard let date else { return -1 } + return Int(date.timeIntervalSince1970 * 1000) + } + + private static func formatOptionalDoubleForSignature(_ value: Double?) -> String { + guard let value else { return "nil" } + return self.formatDoubleForSignature(value) + } + + private static func formatDoubleForSignature(_ value: Double) -> String { + String(format: "%.8f", value) + } + func performMenuMutationWithoutAnimation(_ updates: () -> Void) { CATransaction.begin() CATransaction.setDisableActions(true) diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index d210bff6..4922f9cc 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -1,13 +1,211 @@ import AppKit +import CodexBarCore extension StatusItemController { + private static let defaultClosedMenuPreparationDelay: Duration = .milliseconds(350) + + #if DEBUG + private static var closedMenuPreparationDelayForTesting: Duration = defaultClosedMenuPreparationDelay + static func setClosedMenuPreparationDelayForTesting(_ delay: Duration) { + self.closedMenuPreparationDelayForTesting = delay + } + + static func resetClosedMenuPreparationDelayForTesting() { + self.closedMenuPreparationDelayForTesting = self.defaultClosedMenuPreparationDelay + } + #endif + + private static var closedMenuPreparationDelay: Duration { + #if DEBUG + closedMenuPreparationDelayForTesting + #else + defaultClosedMenuPreparationDelay + #endif + } + + func invalidateMenus( + refreshOpenMenus: Bool = false, + deferOpenParentMenuRebuild: Bool = false, + allowStaleContentDuringDataRefresh: Bool = false) + { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif + self.menuContentVersion &+= 1 + if !allowStaleContentDuringDataRefresh { + self.latestRequiredMenuRebuildVersion = self.menuContentVersion + } + guard self.isMenuRefreshEnabled else { return } + if !self.openMenus.isEmpty { + guard refreshOpenMenus else { return } + self.refreshOpenMenusAllowingParentRebuild( + deferParentRebuildDuringTracking: deferOpenParentMenuRebuild) + self.scheduleOpenMenuInvalidationRetry( + deferParentRebuildDuringTracking: deferOpenParentMenuRebuild) + return + } + self.prepareAttachedClosedMenusIfNeeded() + } + + func prepareAttachedClosedMenusIfNeeded() { + guard self.isMenuRefreshEnabled else { return } + guard self.openMenus.isEmpty else { return } + guard !self.isMenuDataRefreshInFlight else { return } + for menu in self.attachedMenusForClosedPreparation() { + self.rebuildClosedMenuIfNeeded(menu) + } + } + + var isMenuDataRefreshInFlight: Bool { + self.store.isRefreshing || + UsageProvider.allCases.contains { self.store.isTokenRefreshInFlight(for: $0) } + } + + func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) { + guard self.menuNeedsRefresh(menu) else { return } + if self.canPreserveStaleMenuContentDuringRefresh(menu) { + #if DEBUG + self.menuLogger.debug( + "menu open kept existing content during refresh", + metadata: [ + "items": "\(menu.items.count)", + "provider": provider?.rawValue ?? "nil", + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) + #endif + self.deferMenuInteractionRefreshIfNeeded() + return + } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + } + + private func canPreserveStaleMenuContentDuringRefresh(_ menu: NSMenu) -> Bool { + guard self.isMenuDataRefreshInFlight, !menu.items.isEmpty else { return false } + let key = ObjectIdentifier(menu) + guard let menuVersion = self.menuVersions[key] else { return false } + return menuVersion >= self.latestRequiredMenuRebuildVersion + } + + private func attachedMenusForClosedPreparation() -> [NSMenu] { + var menus: [NSMenu] = [] + var seen = Set() + + func append(_ menu: NSMenu?) { + guard let menu else { return } + let key = ObjectIdentifier(menu) + guard seen.insert(key).inserted else { return } + menus.append(menu) + } + + append(self.statusItem.menu) + append(self.mergedMenu) + append(self.fallbackMenu) + for item in self.statusItems.values { + append(item.menu) + } + for menu in self.providerMenus.values { + append(menu) + } + return menus + } + func renderedMenuWidth(for menu: NSMenu) -> CGFloat { let measuredWidth = ceil(menu.size.width) return max(measuredWidth, Self.menuCardBaseWidth) } + func rebuildClosedMenuIfNeeded(_ menu: NSMenu) { + guard !self.hasPreparedForAppShutdown else { return } + guard !self.isMenuDataRefreshInFlight else { return } + let key = ObjectIdentifier(menu) + let provider = self.menuProvider(for: menu) + self.closedMenuRebuildTokenCounter &+= 1 + let rebuildToken = self.closedMenuRebuildTokenCounter + self.closedMenuRebuildTokens[key] = rebuildToken + self.closedMenuRebuildTasks[key]?.cancel() + self.closedMenuRebuildTasks[key] = Task { @MainActor [weak self, weak menu] in + let delay = Self.closedMenuPreparationDelay + if delay > .zero { + try? await Task.sleep(for: delay) + } + guard !Task.isCancelled else { return } + await Task.yield() + guard !Task.isCancelled else { return } + guard let self else { return } + defer { + if self.closedMenuRebuildTokens[key] == rebuildToken { + self.closedMenuRebuildTasks.removeValue(forKey: key) + self.closedMenuRebuildTokens.removeValue(forKey: key) + } + } + guard let menu else { return } + guard self.closedMenuRebuildTokens[key] == rebuildToken else { return } + guard !self.hasPreparedForAppShutdown else { return } + guard !self.isMenuDataRefreshInFlight else { return } + guard self.openMenus[ObjectIdentifier(menu)] == nil else { return } + guard self.menuNeedsRefresh(menu) else { return } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + #if DEBUG + if self.lastLoggedClosedMenuRebuildVersion != self.menuContentVersion { + self.lastLoggedClosedMenuRebuildVersion = self.menuContentVersion + self.menuLogger.debug( + "closed menu rebuild completed", + metadata: [ + "items": "\(menu.items.count)", + "provider": provider?.rawValue ?? "nil", + ]) + } + #endif + } + } + + func cancelClosedMenuRebuild(_ menu: NSMenu) { + let key = ObjectIdentifier(menu) + self.closedMenuRebuildTasks.removeValue(forKey: key)?.cancel() + self.closedMenuRebuildTokens.removeValue(forKey: key) + } + + func cancelAllClosedMenuRebuilds() { + for task in self.closedMenuRebuildTasks.values { + task.cancel() + } + self.closedMenuRebuildTasks.removeAll(keepingCapacity: false) + self.closedMenuRebuildTokens.removeAll(keepingCapacity: false) + } + + func menuNeedsRefresh(_ menu: NSMenu) -> Bool { + let key = ObjectIdentifier(menu) + return self.menuVersions[key] != self.menuContentVersion + } + + func markMenuFresh(_ menu: NSMenu) { + let key = ObjectIdentifier(menu) + self.menuVersions[key] = self.menuContentVersion + } + + func hasOpenHostedSubviewMenu() -> Bool { + self.openMenus.values.contains { self.isHostedSubviewMenu($0) } + } + + func refreshOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) + } + + func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + self.applyIcon(phase: nil) + #if DEBUG + self._test_openMenuRebuildObserver?(menu) + #endif + } + func refreshOpenMenusIfNeeded() { - guard Self.menuRefreshEnabled else { return } + guard self.isMenuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } self.refreshOpenMenusIfNeeded(allowsParentRebuild: false) } @@ -16,13 +214,42 @@ extension StatusItemController { self.refreshOpenMenusAllowingParentRebuild() } - func refreshOpenMenusAllowingParentRebuild() { - guard Self.menuRefreshEnabled else { return } + func refreshOpenMenusAfterHostedSubviewClose() { + guard self.isMenuRefreshEnabled else { return } + guard !self.openMenus.isEmpty else { return } + self.refreshOpenMenusIfNeeded( + allowsParentRebuild: true, + respectsParentRebuildDeferral: true) + } + + func refreshOpenMenusAllowingParentRebuild(deferParentRebuildDuringTracking: Bool = false) { + guard self.isMenuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } - self.refreshOpenMenusIfNeeded(allowsParentRebuild: true) + self.refreshOpenMenusIfNeeded( + allowsParentRebuild: true, + deferParentRebuildDuringTracking: deferParentRebuildDuringTracking) } - private func refreshOpenMenusIfNeeded(allowsParentRebuild: Bool) { + func scheduleOpenMenuInvalidationRetry(deferParentRebuildDuringTracking: Bool = false) { + self.openMenuInvalidationRetryTask?.cancel() + self.openMenuInvalidationRetryTask = Task { @MainActor [weak self] in + guard let self else { return } + await Task.yield() + guard !Task.isCancelled else { return } + #if DEBUG + self.onOpenMenuInvalidationRetryForTesting?() + #endif + self.refreshOpenMenusAllowingParentRebuild( + deferParentRebuildDuringTracking: deferParentRebuildDuringTracking) + self.openMenuInvalidationRetryTask = nil + } + } + + private func refreshOpenMenusIfNeeded( + allowsParentRebuild: Bool, + deferParentRebuildDuringTracking: Bool = false, + respectsParentRebuildDeferral: Bool = false) + { var orphanedKeys: [ObjectIdentifier] = [] let hasOpenHostedSubviewMenu = self.hasOpenHostedSubviewMenu() for (key, menu) in self.openMenus { @@ -33,6 +260,8 @@ extension StatusItemController { self.refreshOpenMenuIfNeeded( menu, allowsParentRebuild: allowsParentRebuild, + deferParentRebuildDuringTracking: deferParentRebuildDuringTracking, + respectsParentRebuildDeferral: respectsParentRebuildDeferral, hasOpenHostedSubviewMenu: hasOpenHostedSubviewMenu) } self.removeOrphanedOpenMenuEntries(orphanedKeys) @@ -41,15 +270,27 @@ extension StatusItemController { private func refreshOpenMenuIfNeeded( _ menu: NSMenu, allowsParentRebuild: Bool, + deferParentRebuildDuringTracking: Bool, + respectsParentRebuildDeferral: Bool, hasOpenHostedSubviewMenu: Bool) { if self.isHostedSubviewMenu(menu) { - self.refreshHostedSubviewHeights(in: menu) + self.refreshHostedSubviewMenu(menu) return } guard allowsParentRebuild else { return } - guard !hasOpenHostedSubviewMenu else { return } guard self.menuNeedsRefresh(menu) else { return } + let key = ObjectIdentifier(menu) + + if deferParentRebuildDuringTracking { + self.parentMenuRebuildsDeferredDuringTracking.insert(key) + return + } + if respectsParentRebuildDeferral, self.parentMenuRebuildsDeferredDuringTracking.contains(key) { + return + } + self.parentMenuRebuildsDeferredDuringTracking.remove(key) + guard !hasOpenHostedSubviewMenu else { return } let provider = self.menuProvider(for: menu) self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) @@ -61,6 +302,7 @@ extension StatusItemController { self.menuRefreshTasks.removeValue(forKey: key)?.cancel() self.menuProviders.removeValue(forKey: key) self.menuVersions.removeValue(forKey: key) + self.parentMenuRebuildsDeferredDuringTracking.remove(key) } } } diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 7c0f3b1b..03fc1785 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -43,7 +43,7 @@ struct OverviewMenuCardRowView: View { .lineLimit(1) Spacer() } - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.hasUsageBlock ? 0 : 8) .padding(.bottom, 6) .frame(width: self.width, alignment: .leading) diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index 3b0f6873..d227a9ae 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -60,7 +60,7 @@ final class ProviderSwitcherShortcutEventMonitor { extension StatusItemController { func installProviderSwitcherShortcutMonitorIfNeeded(for menu: NSMenu) { - guard Self.menuRefreshEnabled, + guard self.isMenuRefreshEnabled, self.shouldMergeIcons, menu.items.first?.view is ProviderSwitcherView else { diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 7dcfdcff..0277aca6 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -46,17 +46,23 @@ extension StatusItemController { for task in self.menuRefreshTasks.values { task.cancel() } + self.cancelAllClosedMenuRebuilds() for task in self.openMenuRebuildTasks.values { task.cancel() } + self.openMenuInvalidationRetryTask?.cancel() + self.openMenuInvalidationRetryTask = nil } private func clearShutdownMenuState() { self.removeProviderSwitcherShortcutMonitor() self.menuRefreshTasks.removeAll(keepingCapacity: false) + self.closedMenuRebuildTasks.removeAll(keepingCapacity: false) + self.closedMenuRebuildTokens.removeAll(keepingCapacity: false) self.openMenuRebuildTasks.removeAll(keepingCapacity: false) self.openMenuRebuildTokens.removeAll(keepingCapacity: false) self.openMenuRebuildsClosingHostedSubviewMenus.removeAll(keepingCapacity: false) + self.parentMenuRebuildsDeferredDuringTracking.removeAll(keepingCapacity: false) self.openMenus.removeAll(keepingCapacity: false) self.highlightedMenuItems.removeAll(keepingCapacity: false) self.menuProviders.removeAll(keepingCapacity: false) diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 97908cfe..2e663921 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -55,6 +55,7 @@ extension StatusItemController { let chartItem = NSMenuItem() chartItem.isEnabled = true chartItem.representedObject = Self.usageHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } @@ -73,6 +74,7 @@ extension StatusItemController { chartItem.view = hosting chartItem.isEnabled = true chartItem.representedObject = Self.usageHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 97ccca62..cbe0ec80 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -30,11 +30,21 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin static let quotaWarningFlashDuration: TimeInterval = 60 private nonisolated static let statusItemAccessibilityTitle = "CodexBar" private nonisolated static let statusItemAccessibilityIdentifierPrefix = "CodexBar.StatusItem" + private nonisolated static let mergedLegacyDefaultItemIndex = 0 - private enum StatusItemIdentity { + enum StatusItemIdentity { case merged case provider(UsageProvider) + var autosaveName: String { + switch self { + case .merged: + "codexbar-merged" + case let .provider(provider): + "codexbar-\(provider.rawValue)" + } + } + var accessibilityIdentifier: String { switch self { case .merged: @@ -46,14 +56,18 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } #if DEBUG - static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { - self.menuRefreshEnabled = enabled - } + var menuRefreshEnabledOverrideForTesting: Bool? + #endif - static func resetMenuRefreshEnabledForTesting() { - self.menuRefreshEnabled = self.defaultMenuRefreshEnabled + var isMenuRefreshEnabled: Bool { + #if DEBUG + if let menuRefreshEnabledOverrideForTesting { + return menuRefreshEnabledOverrideForTesting + } + #endif + return Self.menuRefreshEnabled } - #endif + typealias Factory = @MainActor ( UsageStore, @@ -101,23 +115,36 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastMenuProvider: UsageProvider? var menuProviders: [ObjectIdentifier: UsageProvider] = [:] var menuContentVersion: Int = 0 + var latestRequiredMenuRebuildVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] + var lastMenuAdjunctReadinessSignature = "" var mergedMenu: NSMenu? var providerMenus: [UsageProvider: NSMenu] = [:] var fallbackMenu: NSMenu? var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] + var closedMenuRebuildTasks: [ObjectIdentifier: Task] = [:] + var closedMenuRebuildTokens: [ObjectIdentifier: Int] = [:] + var closedMenuRebuildTokenCounter = 0 var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var openMenuRebuildTokenCounter = 0 var openMenuRebuildsClosingHostedSubviewMenus: Set = [] + var parentMenuRebuildsDeferredDuringTracking: Set = [] + var deferredMenuInteractionRefreshPending = false + var deferredOpenAIDashboardRefreshReason: String? + var deferredMenuInteractionRefreshTask: Task? var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? var providerSwitcherShortcutMenuID: ObjectIdentifier? var hasPreparedForAppShutdown = false + var openMenuInvalidationRetryTask: Task? #if DEBUG var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? + var onDeferredMenuInteractionRefreshForTesting: (() -> Void)? + var onOpenMenuInvalidationRetryForTesting: (() -> Void)? var isReleasedForTesting = false + var lastLoggedClosedMenuRebuildVersion: Int? var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)? var _test_openMenuRebuildObserver: (@MainActor (NSMenu) -> Void)? var _test_codexAmbientLoginRunnerOverride: @@ -197,8 +224,19 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin set { self.settings.selectedMenuProvider = newValue } } - private static func makeStatusItem(statusBar: NSStatusBar, identity: StatusItemIdentity) -> NSStatusItem { + private static func makeStatusItem( + statusBar: NSStatusBar, + identity: StatusItemIdentity, + defaults: UserDefaults, + legacyDefaultItemIndex: Int?) + -> NSStatusItem + { + MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: identity.autosaveName, + legacyDefaultItemIndex: legacyDefaultItemIndex) let item = statusBar.statusItem(withLength: NSStatusItem.variableLength) + item.autosaveName = identity.autosaveName if let button = item.button { // Ensure the icon is rendered at 1:1 without resampling (crisper edges for template images). button.imageScaling = .scaleNone @@ -318,7 +356,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let repairedStatusItemVisibilityKeys = MenuBarStatusItemDefaultsRepair .repairHiddenVisibilityDefaultsIfNeeded(defaults: settings.userDefaults) self.statusBar = statusBar - self.statusItem = Self.makeStatusItem(statusBar: statusBar, identity: .merged) + self.statusItem = Self.makeStatusItem( + statusBar: statusBar, + identity: .merged, + defaults: settings.userDefaults, + legacyDefaultItemIndex: Self.mergedLegacyDefaultItemIndex) self.lastKnownScreenCount = NSScreen.screens.count // Status items for individual providers are now created lazily in updateVisibility() super.init() @@ -327,6 +369,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin "Repaired hidden macOS status-item visibility defaults", metadata: ["keys": repairedStatusItemVisibilityKeys.joined(separator: ",")]) } + self.lastMenuAdjunctReadinessSignature = self.menuAdjunctReadinessSignature() self.wireBindings() self.updateVisibility() self.updateIcons() @@ -398,7 +441,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin Task { @MainActor [weak self] in guard let self else { return } self.observeStoreChanges() - self.invalidateMenus() + self.invalidateMenus( + refreshOpenMenus: self.didMenuAdjunctReadinessChange(), + deferOpenParentMenuRebuild: true, + allowStaleContentDuringDataRefresh: true) } } } @@ -563,33 +609,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - func invalidateMenus(refreshOpenMenus: Bool = false) { - #if DEBUG - guard !self.isReleasedForTesting else { return } - #endif - self.menuContentVersion &+= 1 - guard Self.menuRefreshEnabled else { return } - if !self.openMenus.isEmpty { - guard refreshOpenMenus else { return } - self.refreshOpenMenusAllowingParentRebuild() - Task { @MainActor [weak self] in - guard let self else { return } - // AppKit can ignore menu mutations while tracking; retry on the next run loop. - await Task.yield() - self.refreshOpenMenusAllowingParentRebuild() - } - return - } - self.refreshOpenMenusIfNeeded() - Task { @MainActor [weak self] in - guard let self else { return } - // AppKit can ignore menu mutations while tracking; retry on the next run loop. - await Task.yield() - guard self.openMenus.isEmpty else { return } - self.refreshOpenMenusIfNeeded() - } - } - private func shouldRefreshOpenMenusForProviderSwitcher() -> Bool { var shouldRefresh = false let revision = self.settings.configRevision @@ -683,7 +702,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if let existing = self.statusItems[provider] { return existing } - let item = Self.makeStatusItem(statusBar: self.statusBar, identity: .provider(provider)) + let item = Self.makeStatusItem( + statusBar: self.statusBar, + identity: .provider(provider), + defaults: self.settings.userDefaults, + legacyDefaultItemIndex: self.legacyDefaultItemIndex(forNewProvider: provider)) self.statusItems[provider] = item return item } @@ -694,7 +717,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #endif self.statusItem.menu = nil self.statusBar.removeStatusItem(self.statusItem) - self.statusItem = Self.makeStatusItem(statusBar: self.statusBar, identity: .merged) + self.statusItem = Self.makeStatusItem( + statusBar: self.statusBar, + identity: .merged, + defaults: self.settings.userDefaults, + legacyDefaultItemIndex: Self.mergedLegacyDefaultItemIndex) for provider in Array(self.statusItems.keys) { self.removeProviderStatusItem(for: provider) } @@ -766,6 +793,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if self.statusItem.menu !== self.mergedMenu { self.statusItem.menu = self.mergedMenu } + self.prepareAttachedClosedMenusIfNeeded() } private func attachMenus(fallback: UsageProvider? = nil) { @@ -796,6 +824,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin item.menu = nil } } + self.prepareAttachedClosedMenusIfNeeded() } private func rebuildProviderStatusItems() { @@ -826,6 +855,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.openMenuRebuildTasks.removeValue(forKey: menuID)?.cancel() self.openMenuRebuildTokens.removeValue(forKey: menuID) self.openMenuRebuildsClosingHostedSubviewMenus.remove(menuID) + self.parentMenuRebuildsDeferredDuringTracking.remove(menuID) self.highlightedMenuItems.removeValue(forKey: menuID) } @@ -870,7 +900,25 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } +#if DEBUG extension StatusItemController { + static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { + self.menuRefreshEnabled = enabled + } + + static func resetMenuRefreshEnabledForTesting() { + self.menuRefreshEnabled = self.defaultMenuRefreshEnabled + } +} +#endif + +extension StatusItemController { + private func legacyDefaultItemIndex(forNewProvider provider: UsageProvider) -> Int? { + let visibleProviders = self.settings.orderedProviders().filter { self.isVisible($0) } + guard let providerOffset = visibleProviders.firstIndex(of: provider) else { return nil } + return Self.mergedLegacyDefaultItemIndex + 1 + providerOffset + } + func refreshExistingStatusItemsForVisibilityRecovery() { #if DEBUG guard !self.isReleasedForTesting else { return } diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index 276a5365..6e26c985 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -16,7 +16,7 @@ struct StorageMenuCardSectionView: View { Text(self.storageText) .font(.caption) } - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) diff --git a/Sources/CodexBar/UsageStore+MemoryPressure.swift b/Sources/CodexBar/UsageStore+MemoryPressure.swift new file mode 100644 index 00000000..97e3d12b --- /dev/null +++ b/Sources/CodexBar/UsageStore+MemoryPressure.swift @@ -0,0 +1,19 @@ +import Foundation + +@MainActor +extension UsageStore { + func scheduleMemoryPressureRelief() { + guard self.memoryPressureReliefTask == nil else { return } + + self.memoryPressureReliefTask = Task.detached(priority: .utility) { [weak self] in + for delay in [Duration.seconds(2), .seconds(8), .seconds(20)] { + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + MemoryPressureRelief.releaseFreeMallocPages() + } + await MainActor.run { [weak self] in + self?.memoryPressureReliefTask = nil + } + } + } +} diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 3b75b446..8effa6c3 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -15,6 +15,7 @@ struct OpenAIWebRefreshPolicyContext { let accessEnabled: Bool let batterySaverEnabled: Bool let force: Bool + let refreshPhase: ProviderRefreshPhase } // MARK: - OpenAI web lifecycle @@ -26,6 +27,7 @@ extension UsageStore { let expectedGuard: CodexAccountScopedRefreshGuard? let refreshTaskToken: UUID let allowCodexUsageBackfill: Bool + let force: Bool } private struct OpenAIDashboardCookieImportRequest { @@ -50,6 +52,19 @@ extension UsageStore { afterCookieImport ? self.openAIWebPostImportFetchTimeout : self.openAIWebRetryFetchTimeout } + nonisolated static func refreshPhase( + hasCompletedInitialRefresh: Bool) -> ProviderRefreshPhase + { + hasCompletedInitialRefresh ? .regular : .startup + } + + nonisolated static func openAIWebRefreshPhase( + providerRefreshPhase: ProviderRefreshPhase, + startupConnectivityRetryAttempt: Int?) -> ProviderRefreshPhase + { + startupConnectivityRetryAttempt == nil ? providerRefreshPhase : .startup + } + private func openAIWebRefreshIntervalSeconds() -> TimeInterval { let base = max(self.settings.refreshFrequency.seconds ?? 0, 120) return base * Self.openAIWebRefreshMultiplier @@ -62,13 +77,30 @@ extension UsageStore { 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 dashboard = self.openAIDashboard ?? self.lastOpenAIDashboardSnapshot + let lastUpdatedAt = dashboard?.updatedAt + let needsMenuHistoryRefresh = dashboard?.dailyBreakdown.isEmpty == true && + dashboard?.usageBreakdown.isEmpty == true + if needsMenuHistoryRefresh, + Self.shouldSkipOpenAIWebEmptyHistoryRetry(.init( + force: false, + accountDidChange: self.openAIWebAccountDidChange, + lastError: self.lastOpenAIDashboardError, + lastSnapshotAt: lastUpdatedAt, + lastAttemptAt: self.lastOpenAIDashboardAttemptAt, + now: now, + refreshInterval: refreshInterval)) + { + return + } + if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) < refreshInterval, !needsMenuHistoryRefresh { + 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( + batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled) || needsMenuHistoryRefresh + self.openAIWebLogger.info( "OpenAI web stale refresh gate", metadata: [ "reason": reason, @@ -402,7 +434,8 @@ extension UsageStore { allowCurrentSnapshotFallback: allowCurrentSnapshotFallback, expectedGuard: expectedGuard, refreshTaskToken: taskToken, - allowCodexUsageBackfill: allowCodexUsageBackfill) + allowCodexUsageBackfill: allowCodexUsageBackfill, + force: force) let task = Task { [weak self] in guard let self else { return } await self.performOpenAIDashboardRefreshIfNeeded(context) @@ -509,6 +542,7 @@ extension UsageStore { var dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: log, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebDashboardFetchTimeout(didImportCookies: didImportCookiesForRefresh)) guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } @@ -524,6 +558,7 @@ extension UsageStore { dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: log, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } } @@ -573,6 +608,17 @@ extension UsageStore { latestCookieImportStatus: inout String?, logger: @escaping (String) -> Void) async { + if !context.force { + OpenAIDashboardFetcher.evictAllCachedWebViews() + logger("OpenAI web refresh timed out; skipping immediate background retry.") + await self.applyOpenAIDashboardFailure( + message: "OpenAI web dashboard refresh timed out. CodexBar will retry after the refresh cooldown.", + expectedGuard: context.expectedGuard, + refreshTaskToken: context.refreshTaskToken, + routingTargetEmail: context.targetEmail) + return + } + let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) @@ -599,6 +645,7 @@ extension UsageStore { let dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: logger, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboard( @@ -650,6 +697,7 @@ extension UsageStore { let dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: logger, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboard( @@ -713,6 +761,7 @@ extension UsageStore { let dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: logger, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboard( @@ -914,6 +963,9 @@ extension UsageStore { } func invalidateOpenAIDashboardRefreshTask() { + self.openAIDashboardBackgroundRefreshTask?.cancel() + self.openAIDashboardBackgroundRefreshTask = nil + self.openAIDashboardBackgroundRefreshTaskKey = nil self.openAIDashboardRefreshTask?.cancel() self.openAIDashboardRefreshTask = nil self.openAIDashboardRefreshTaskKey = nil @@ -927,15 +979,17 @@ extension UsageStore { private func loadLatestOpenAIDashboard( accountEmail: String?, logger: @escaping (String) -> Void, + allowNavigationTimeoutRetry: Bool, timeout: TimeInterval) async throws -> OpenAIDashboardSnapshot { if let override = self._test_openAIDashboardLoaderOverride { - return try await override(accountEmail, logger, timeout) + return try await override(accountEmail, logger, allowNavigationTimeoutRetry, timeout) } return try await OpenAIDashboardFetcher().loadLatestDashboard( accountEmail: accountEmail, logger: logger, debugDumpHTML: timeout != Self.openAIWebPrimaryFetchTimeout, + allowNavigationTimeoutRetry: allowNavigationTimeoutRetry, timeout: timeout) } @@ -1284,6 +1338,7 @@ extension UsageStore { extension UsageStore { nonisolated static func shouldRunOpenAIWebRefresh(_ context: OpenAIWebRefreshPolicyContext) -> Bool { guard context.accessEnabled else { return false } + guard context.force || context.refreshPhase != .startup else { return false } return context.force || !context.batterySaverEnabled } @@ -1307,6 +1362,15 @@ extension UsageStore { return false } + nonisolated static func shouldSkipOpenAIWebEmptyHistoryRetry(_ context: OpenAIWebRefreshGateContext) -> Bool { + if context.force || context.accountDidChange { return false } + guard let lastAttemptAt = context.lastAttemptAt, + context.now.timeIntervalSince(lastAttemptAt) < context.refreshInterval + else { return false } + guard let lastSnapshotAt = context.lastSnapshotAt else { return true } + return lastAttemptAt >= lastSnapshotAt + } + func syncOpenAIWebState() { guard self.isEnabled(.codex), self.settings.openAIWebAccessEnabled, diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 82b81708..2f87fbc5 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -115,6 +115,7 @@ extension UsageStore { providerBuckets.setHistories(updatedHistories, for: accountKey) self.planUtilizationHistory[provider] = providerBuckets + self.planUtilizationHistoryRevision &+= 1 snapshotToPersist = self.planUtilizationHistory } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index db3c9d2e..22c5b19f 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -148,6 +148,7 @@ extension UsageStore { { return } + self.recordStartupConnectivityRetryableFailure(error) if claudeCredentialsChanged { await self.clearClaudeCredentialDerivedStateForCredentialSwap() } @@ -299,6 +300,16 @@ extension UsageStore { let shouldSurface = self.failureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true + let preservesClaudeWebSessionFailure = + provider == .claude && + hadPriorData && + Self.isClaudeWebSessionRefreshFailure(error) + if preservesClaudeWebSessionFailure, + !shouldSurface + { + self.errors[provider] = nil + return + } if provider == .claude, preservesPriorData, Self.isClaudeUsageProbeTimeout(error) @@ -312,7 +323,7 @@ extension UsageStore { } if shouldSurface { self.errors[provider] = error.localizedDescription - if !preservesPriorData { + if !preservesPriorData, !preservesClaudeWebSessionFailure { self.snapshots.removeValue(forKey: provider) } } else { @@ -359,11 +370,50 @@ extension UsageStore { } } + static func startupConnectivityRetryDelay(forAttempt attempt: Int) -> TimeInterval? { + let delays: [TimeInterval] = [15, 45, 120, 300] + guard attempt >= 1, attempt <= delays.count else { return nil } + return delays[attempt - 1] + } + + static func isStartupConnectivityRetryableError(_ error: Error) -> Bool { + if error is CancellationError { return false } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorTimedOut, + NSURLErrorNetworkConnectionLost, + NSURLErrorNotConnectedToInternet, + NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorDNSLookupFailed: + return true + default: + return false + } + } + + let message = error.localizedDescription.lowercased() + return message.contains("timed out") || + message.contains("timeout") || + message.contains("network connection was lost") || + message.contains("not connected to the internet") || + message.contains("cannot find host") || + message.contains("cannot connect to host") || + message.contains("dns lookup") + } + private static func isClaudeUsageProbeTimeout(_ error: Error) -> Bool { if case ClaudeStatusProbeError.timedOut = error { return true } return error.localizedDescription == ClaudeStatusProbeError.timedOut.localizedDescription } + private static func isClaudeWebSessionRefreshFailure(_ error: Error) -> Bool { + if case ClaudeWebAPIFetcher.FetchError.unauthorized = error { return true } + return error.localizedDescription == ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription + } + nonisolated static func isPermissionPromptWaiting(_ error: Error) -> Bool { let message = error.localizedDescription.lowercased() return (message.contains("prompt") && message.contains("waiting")) || diff --git a/Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift b/Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift new file mode 100644 index 00000000..aceceb48 --- /dev/null +++ b/Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift @@ -0,0 +1,83 @@ +import Foundation + +extension UsageStore { + enum StartupBehavior { + case automatic + case full + case testing + + var automaticallyStartsBackgroundWork: Bool { + switch self { + case .automatic, .full: + true + case .testing: + false + } + } + + func resolved(isRunningTests: Bool) -> StartupBehavior { + switch self { + case .automatic: + isRunningTests ? .testing : .full + case .full, .testing: + self + } + } + } + + func recordStartupConnectivityRetryableFailure(_ error: Error) { + guard self.startupConnectivityRetryRefreshActive else { return } + guard Self.isStartupConnectivityRetryableError(error) else { return } + self.startupConnectivityRetryNeeded = true + } + + func completeStartupConnectivityRetryPass(currentAttempt: Int) { + guard self.startupConnectivityRetryNeeded else { + self.cancelStartupConnectivityRetry() + return + } + + let nextAttempt = currentAttempt + 1 + guard let delay = Self.startupConnectivityRetryDelay(forAttempt: nextAttempt) else { + self.cancelStartupConnectivityRetry() + return + } + + self.scheduleStartupConnectivityRetry(attempt: nextAttempt, delay: delay) + } + + private func scheduleStartupConnectivityRetry(attempt: Int, delay: TimeInterval) { + guard self.startupBehavior.automaticallyStartsBackgroundWork || + self._test_startupConnectivityRetryScheduled != nil || + self._test_startupConnectivityRetrySleepOverride != nil + else { + return + } + + self.startupConnectivityRetryTask?.cancel() + self._test_startupConnectivityRetryScheduled?(attempt, delay) + self.startupConnectivityRetryTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await self.sleepForStartupConnectivityRetry(delay) + guard !Task.isCancelled else { return } + await self.runRefresh(startupConnectivityRetryAttempt: attempt) + } catch { + return + } + } + } + + private func cancelStartupConnectivityRetry() { + self.startupConnectivityRetryTask?.cancel() + self.startupConnectivityRetryTask = nil + } + + private func sleepForStartupConnectivityRetry(_ delay: TimeInterval) async throws { + if let override = self._test_startupConnectivityRetrySleepOverride { + try await override(delay) + return + } + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } +} diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 13c162ed..44d2e4e6 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -14,6 +14,36 @@ extension UsageStore { self.lastTokenFetchAt[provider] } + func hydrateCachedTokenSnapshots(now: Date = Date()) { + guard self.settings.costUsageEnabled else { return } + guard self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata).contains(.codex) else { + return + } + + let scope = self.tokenCostScope(for: .codex) + let historyDays = self.settings.costUsageHistoryDays + Task { @MainActor [weak self] in + guard let self else { return } + guard self.tokenSnapshots[.codex] == nil else { return } + guard let snapshot = await self.costUsageFetcher.loadCachedCodexTokenSnapshot( + now: now, + codexHomePath: scope.codexHomePath, + historyDays: historyDays) + else { + return + } + guard self.settings.costUsageEnabled, + self.isEnabled(.codex), + self.tokenCostScope(for: .codex).signature == scope.signature, + self.tokenSnapshots[.codex] == nil + else { + return + } + self.tokenSnapshots[.codex] = snapshot + self.tokenErrors[.codex] = nil + } + } + func isTokenRefreshInFlight(for provider: UsageProvider) -> Bool { self.tokenRefreshInFlight.contains(provider) } @@ -63,6 +93,35 @@ extension UsageStore { .appendingPathComponent("cost-usage", isDirectory: true) } + func clearCostUsageCache() async -> String? { + let errorMessage: String? = await Task.detached(priority: .utility) { + let fm = FileManager.default + let cacheDirs = [ + Self.costUsageCacheDirectory(fileManager: fm), + ] + + for cacheDir in cacheDirs { + do { + try fm.removeItem(at: cacheDir) + } catch let error as NSError { + if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { continue } + return error.localizedDescription + } + } + return nil + }.value + + guard errorMessage == nil else { return errorMessage } + + self.tokenSnapshots.removeAll() + self.tokenErrors.removeAll() + self.lastTokenFetchAt.removeAll() + self.lastTokenFetchScope.removeAll() + self.tokenFailureGates[.codex]?.reset() + self.tokenFailureGates[.claude]?.reset() + return nil + } + nonisolated static func tokenCostNoDataMessage(for provider: UsageProvider) -> String { ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.noDataMessage() } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 3d7de8e8..ef178a8b 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -25,6 +25,7 @@ extension UsageStore { _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin + _ = self.openAIDashboardAttachmentRevision _ = self.versions _ = self.isRefreshing _ = self.refreshingProviders @@ -32,6 +33,7 @@ extension UsageStore { _ = self.statuses _ = self.probeLogs _ = self.historicalPaceRevision + _ = self.planUtilizationHistoryRevision _ = self.providerStorageFootprints return 0 } @@ -116,30 +118,6 @@ final class UsageStore { case dashboardWeb } - enum StartupBehavior { - case automatic - case full - case testing - - var automaticallyStartsBackgroundWork: Bool { - switch self { - case .automatic, .full: - true - case .testing: - false - } - } - - func resolved(isRunningTests: Bool) -> StartupBehavior { - switch self { - case .automatic: - isRunningTests ? .testing : .full - case .full, .testing: - self - } - } - } - var snapshots: [UsageProvider: UsageSnapshot] = [:] var errors: [UsageProvider: String] = [:] var lastSourceLabels: [UsageProvider: String] = [:] @@ -165,12 +143,20 @@ final class UsageStore { var statuses: [UsageProvider: ProviderStatus] = [:] var probeLogs: [UsageProvider: String] = [:] var historicalPaceRevision: Int = 0 + var planUtilizationHistoryRevision: Int = 0 var providerStorageFootprints: [UsageProvider: ProviderStorageFootprint] = [:] @ObservationIgnored var lastCreditsSnapshot: CreditsSnapshot? @ObservationIgnored var lastCreditsSnapshotAccountKey: String? @ObservationIgnored var lastCreditsSource: CodexCreditsSource = .none @ObservationIgnored var creditsFailureStreak: Int = 0 - @ObservationIgnored var openAIDashboardAttachmentAuthorized: Bool = false + @ObservationIgnored var openAIDashboardAttachmentAuthorized: Bool = false { + didSet { + guard self.openAIDashboardAttachmentAuthorized != oldValue else { return } + self.openAIDashboardAttachmentRevision &+= 1 + } + } + + var openAIDashboardAttachmentRevision = 0 @ObservationIgnored var lastOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? @ObservationIgnored var lastOpenAIDashboardAttachmentAuthorized: Bool = false @ObservationIgnored var lastOpenAIDashboardTargetEmail: String? @@ -196,16 +182,22 @@ final class UsageStore { @ObservationIgnored var _test_openAIDashboardLoaderOverride: (@MainActor ( String?, @escaping (String) -> Void, + Bool, TimeInterval) async throws -> OpenAIDashboardSnapshot)? @ObservationIgnored var _test_codexCreditsLoaderOverride: (@MainActor () async throws -> CreditsSnapshot)? @ObservationIgnored var _test_widgetSnapshotSaveOverride: (@MainActor (WidgetSnapshot) async -> Void)? @ObservationIgnored var _test_providerRefreshOverride: (@MainActor (UsageProvider) async -> Void)? @ObservationIgnored var _test_tokenUsageRefreshOverride: (@MainActor (UsageProvider, Bool) async -> Void)? + @ObservationIgnored var _test_providerStatusFetchOverride: (@MainActor ( + UsageProvider) async throws -> ProviderStatus)? + @ObservationIgnored var _test_startupConnectivityRetryScheduled: (@MainActor (Int, TimeInterval) -> Void)? + @ObservationIgnored var _test_startupConnectivityRetrySleepOverride: (@MainActor ( + TimeInterval) async throws -> Void)? @ObservationIgnored var widgetSnapshotPersistTask: Task? @ObservationIgnored let codexFetcher: UsageFetcher @ObservationIgnored let claudeFetcher: any ClaudeUsageFetching - @ObservationIgnored private let costUsageFetcher: CostUsageFetcher + @ObservationIgnored let costUsageFetcher: CostUsageFetcher @ObservationIgnored let browserDetection: BrowserDetection @ObservationIgnored private let registry: ProviderRegistry @ObservationIgnored let settings: SettingsStore @@ -227,6 +219,10 @@ final class UsageStore { @ObservationIgnored private var timerTask: Task? @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? + @ObservationIgnored var memoryPressureReliefTask: Task? + @ObservationIgnored var startupConnectivityRetryTask: Task? + @ObservationIgnored var startupConnectivityRetryNeeded = false + @ObservationIgnored var startupConnectivityRetryRefreshActive = false @ObservationIgnored var storageRefreshTask: Task? @ObservationIgnored var storageRefreshGeneration: UInt64 = 0 @ObservationIgnored var storageRefreshInFlightSignature: String? @@ -253,7 +249,7 @@ final class UsageStore { @ObservationIgnored private let providerAvailabilityCacheTTL: TimeInterval = 1 @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 - @ObservationIgnored private let startupBehavior: StartupBehavior + @ObservationIgnored let startupBehavior: StartupBehavior @ObservationIgnored let planUtilizationPersistenceCoordinator: PlanUtilizationHistoryPersistenceCoordinator init( @@ -320,6 +316,7 @@ final class UsageStore { effectivePATH: PathBuilder.effectivePATH(purposes: [.rpc, .tty, .nodeTooling]), loginShellPATH: LoginShellPathCache.shared.current?.joined(separator: ":")) guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } + self.hydrateCachedTokenSnapshots() self.detectVersions() self.updateProviderRuntimes() Task { @MainActor [weak self] in @@ -533,9 +530,23 @@ final class UsageStore { } func refresh(forceTokenUsage: Bool = false) async { + await self.runRefresh(forceTokenUsage: forceTokenUsage, startupConnectivityRetryAttempt: nil) + } + + func runRefresh( + forceTokenUsage: Bool = false, + startupConnectivityRetryAttempt: Int?) + async + { guard !self.isRefreshing else { return } self.prepareRefreshState() - let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup + let refreshPhase = Self.refreshPhase(hasCompletedInitialRefresh: self.hasCompletedInitialRefresh) + let openAIWebRefreshPhase = Self.openAIWebRefreshPhase( + providerRefreshPhase: refreshPhase, + startupConnectivityRetryAttempt: startupConnectivityRetryAttempt) + let allowsStartupConnectivityRetry = refreshPhase == .startup || startupConnectivityRetryAttempt != nil + self.startupConnectivityRetryRefreshActive = allowsStartupConnectivityRetry + self.startupConnectivityRetryNeeded = false let displayEnabledProviders = self.enabledProvidersForDisplay() let enabledProviderSet = Set(displayEnabledProviders) let refreshProviders = self.enabledProvidersForBackgroundWork() @@ -547,6 +558,7 @@ final class UsageStore { defer { self.isRefreshing = false self.hasCompletedInitialRefresh = true + self.startupConnectivityRetryRefreshActive = false } self.clearDisabledProviderState(enabledProviders: enabledProviderSet) @@ -586,7 +598,8 @@ final class UsageStore { self.settings.openAIWebAccessEnabled && self.settings.codexCookieSource.isEnabled, batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled, - force: forceTokenUsage) + force: forceTokenUsage, + refreshPhase: openAIWebRefreshPhase) let shouldRefreshOpenAIWeb = Self.shouldRunOpenAIWebRefresh(refreshPolicy) self.openAIWebLogger.debug( "OpenAI web refresh gate", @@ -596,7 +609,7 @@ final class UsageStore { "batterySaverEnabled": refreshPolicy.batterySaverEnabled ? "1" : "0", "force": refreshPolicy.force ? "1" : "0", "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", - "phase": refreshPhase == .startup ? "startup" : "regular", + "phase": openAIWebRefreshPhase == .startup ? "startup" : "regular", ]) if shouldRefreshOpenAIWeb { let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() @@ -616,6 +629,13 @@ final class UsageStore { self.persistWidgetSnapshot(reason: "refresh") } + + if allowsStartupConnectivityRetry { + self.completeStartupConnectivityRetryPass(currentAttempt: startupConnectivityRetryAttempt ?? 0) + } + if refreshPhase == .startup { + self.scheduleMemoryPressureRelief() + } } /// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot. @@ -696,12 +716,15 @@ final class UsageStore { if Task.isCancelled { break } await self.refreshTokenUsage(provider, force: force) } + self.scheduleMemoryPressureRelief() } deinit { self.timerTask?.cancel() self.tokenTimerTask?.cancel() self.tokenRefreshSequenceTask?.cancel() + self.memoryPressureReliefTask?.cancel() + self.startupConnectivityRetryTask?.cancel() self.storageRefreshTask?.cancel() self.codexPlanHistoryBackfillTask?.cancel() } @@ -919,7 +942,9 @@ final class UsageStore { do { let status: ProviderStatus - if let urlString = meta.statusPageURL, let baseURL = URL(string: urlString) { + if let override = self._test_providerStatusFetchOverride { + status = try await override(provider) + } else if let urlString = meta.statusPageURL, let baseURL = URL(string: urlString) { status = try await Self.fetchStatus(from: baseURL) } else if let productID = meta.statusWorkspaceProductID { status = try await Self.fetchWorkspaceStatus(productID: productID) @@ -928,6 +953,7 @@ final class UsageStore { } await MainActor.run { self.statuses[provider] = status } } catch { + self.recordStartupConnectivityRetryableFailure(error) // Keep the previous status to avoid flapping when the API hiccups. await MainActor.run { if self.statuses[provider] == nil { @@ -1481,35 +1507,6 @@ extension UsageStore { } } - func clearCostUsageCache() async -> String? { - let errorMessage: String? = await Task.detached(priority: .utility) { - let fm = FileManager.default - let cacheDirs = [ - Self.costUsageCacheDirectory(fileManager: fm), - ] - - for cacheDir in cacheDirs { - do { - try fm.removeItem(at: cacheDir) - } catch let error as NSError { - if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { continue } - return error.localizedDescription - } - } - return nil - }.value - - guard errorMessage == nil else { return errorMessage } - - self.tokenSnapshots.removeAll() - self.tokenErrors.removeAll() - self.lastTokenFetchAt.removeAll() - self.lastTokenFetchScope.removeAll() - self.tokenFailureGates[.codex]?.reset() - self.tokenFailureGates[.claude]?.reset() - return nil - } - private func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { self.tokenSnapshots.removeValue(forKey: provider) diff --git a/Sources/CodexBarCLI/CLILocalHTTPServer.swift b/Sources/CodexBarCLI/CLILocalHTTPServer.swift index 65a486db..701ef09a 100644 --- a/Sources/CodexBarCLI/CLILocalHTTPServer.swift +++ b/Sources/CodexBarCLI/CLILocalHTTPServer.swift @@ -134,7 +134,7 @@ enum CLIHTTPStatus { case notFound case methodNotAllowed case internalServerError - + case gatewayTimeout var code: Int { switch self { case .ok: 200 @@ -143,6 +143,7 @@ enum CLIHTTPStatus { case .notFound: 404 case .methodNotAllowed: 405 case .internalServerError: 500 + case .gatewayTimeout: 504 } } @@ -154,6 +155,7 @@ enum CLIHTTPStatus { case .notFound: "Not Found" case .methodNotAllowed: "Method Not Allowed" case .internalServerError: "Internal Server Error" + case .gatewayTimeout: "Gateway Timeout" } } } diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 569780b4..460ee310 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -17,6 +17,11 @@ struct ServeOptions: CommanderParsable { @Option(name: .long("refresh-interval"), help: "Response cache TTL in seconds (default: 60)") var refreshInterval: Double? + + @Option( + name: .long("request-timeout"), + help: "Total per-request deadline in seconds; 0 disables (default: 30)") + var requestTimeout: Double? } enum CLIServeRoute: Equatable { @@ -60,15 +65,82 @@ private struct ServeHealthPayload: Encodable { let status: String } -private actor CLIServeResponseCache { +private final class CLIServeDeadlineState: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var workTask: Task? + private var timeoutTask: Task? + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func setWorkTask(_ task: Task) { + var shouldCancel = false + self.lock.lock() + if self.continuation == nil { + shouldCancel = true + } else { + self.workTask = task + } + self.lock.unlock() + + if shouldCancel { + task.cancel() + } + } + + func setTimeoutTask(_ task: Task) { + var shouldCancel = false + self.lock.lock() + if self.continuation == nil { + shouldCancel = true + } else { + self.timeoutTask = task + } + self.lock.unlock() + + if shouldCancel { + task.cancel() + } + } + + func finish(_ response: CLILocalHTTPResponse, cancelWork: Bool, cancelTimeout: Bool) { + let continuation: CheckedContinuation? + let workTask: Task? + let timeoutTask: Task? + + self.lock.lock() + continuation = self.continuation + self.continuation = nil + workTask = cancelWork ? self.workTask : nil + timeoutTask = cancelTimeout ? self.timeoutTask : nil + self.workTask = nil + self.timeoutTask = nil + self.lock.unlock() + + workTask?.cancel() + timeoutTask?.cancel() + continuation?.resume(returning: response) + } +} + +private enum CLIServeCacheLookup { + case response(CLILocalHTTPResponse) + case miss +} + +actor CLIServeResponseCache { private struct Entry { let expiresAt: Date let response: CLILocalHTTPResponse } private var entries: [String: Entry] = [:] + private var inFlightKeys: Set = [] + private var waiters: [String: [CheckedContinuation]] = [:] - func response(for key: String, now: Date) -> CLILocalHTTPResponse? { + private func response(for key: String, now: Date) -> CLILocalHTTPResponse? { guard let entry = self.entries[key] else { return nil } guard entry.expiresAt > now else { self.entries[key] = nil @@ -77,7 +149,39 @@ private actor CLIServeResponseCache { return entry.response } - func store(_ response: CLILocalHTTPResponse, for key: String, ttl: TimeInterval, now: Date) { + fileprivate func responseOrStartFetch(for key: String, now: Date) async -> CLIServeCacheLookup { + if let cached = self.response(for: key, now: now) { + return .response(cached) + } + + if self.inFlightKeys.contains(key) { + return await withCheckedContinuation { continuation in + self.waiters[key, default: []].append(continuation) + } + } + + self.inFlightKeys.insert(key) + return .miss + } + + fileprivate func completeFetch( + _ response: CLILocalHTTPResponse, + for key: String, + ttl: TimeInterval, + now: Date, + shouldCache: Bool) + { + if shouldCache { + self.store(response, for: key, ttl: ttl, now: now) + } + self.inFlightKeys.remove(key) + let waiters = self.waiters.removeValue(forKey: key) ?? [] + for waiter in waiters { + waiter.resume(returning: .response(response)) + } + } + + private func store(_ response: CLILocalHTTPResponse, for key: String, ttl: TimeInterval, now: Date) { guard ttl > 0, response.status == .ok else { return } self.entries[key] = Entry(expiresAt: now.addingTimeInterval(ttl), response: response) } @@ -86,6 +190,7 @@ private actor CLIServeResponseCache { private enum CLIServeArgumentError: LocalizedError { case invalidPort case invalidRefreshInterval + case invalidRequestTimeout case invalidProvider(String) var errorDescription: String? { @@ -94,6 +199,8 @@ private enum CLIServeArgumentError: LocalizedError { "--port must be between 1 and 65535." case .invalidRefreshInterval: "--refresh-interval must be zero or greater." + case .invalidRequestTimeout: + "--request-timeout must be zero or greater." case let .invalidProvider(provider): "Unknown provider '\(provider)'." } @@ -101,10 +208,13 @@ private enum CLIServeArgumentError: LocalizedError { } extension CodexBarCLI { + static let defaultServeRequestTimeout: TimeInterval = 30 + static func runServe(_ values: ParsedValues) async { let output = CLIOutputPreferences(format: .json, jsonOnly: true, pretty: false) let port = Self.decodeServePort(from: values) let refreshInterval = Self.decodeServeRefreshInterval(from: values) + let requestTimeout = Self.decodeServeRequestTimeout(from: values) guard let port else { Self.exit( @@ -122,6 +232,14 @@ extension CodexBarCLI { kind: .args) } + guard let requestTimeout else { + Self.exit( + code: .failure, + message: CLIServeArgumentError.invalidRequestTimeout.localizedDescription, + output: output, + kind: .args) + } + let config = Self.loadConfig(output: output) let cache = CLIServeResponseCache() let server = CLILocalHTTPServer(host: "127.0.0.1", port: port) { request in @@ -129,7 +247,8 @@ extension CodexBarCLI { request, config: config, cache: cache, - refreshInterval: refreshInterval) + refreshInterval: refreshInterval, + requestTimeout: requestTimeout) } do { @@ -167,11 +286,25 @@ extension CodexBarCLI { return parsed } + static func decodeServeRequestTimeout(from values: ParsedValues) -> TimeInterval? { + let raw = values.options["requestTimeout"]?.last + let parsed: Double + if let raw { + guard let value = Double(raw) else { return nil } + parsed = value + } else { + parsed = Self.defaultServeRequestTimeout + } + guard parsed >= 0 else { return nil } + return parsed + } + private static func handleServeRequest( _ request: CLILocalHTTPRequest, config: CodexBarConfig, cache: CLIServeResponseCache, - refreshInterval: TimeInterval) async -> CLILocalHTTPResponse + refreshInterval: TimeInterval, + requestTimeout: TimeInterval) async -> CLILocalHTTPResponse { let route: CLIServeRoute do { @@ -192,7 +325,8 @@ extension CodexBarCLI { return await Self.cachedServeResponse( key: "usage:\(provider ?? "")", cache: cache, - refreshInterval: refreshInterval) + refreshInterval: refreshInterval, + requestTimeout: requestTimeout) { await Self.serveUsage(provider: provider, config: config) } @@ -200,29 +334,69 @@ extension CodexBarCLI { return await Self.cachedServeResponse( key: "cost:\(provider ?? "")", cache: cache, - refreshInterval: refreshInterval) + refreshInterval: refreshInterval, + requestTimeout: requestTimeout) { await Self.serveCost(provider: provider, config: config) } } } - private static func cachedServeResponse( + static func cachedServeResponse( key: String, cache: CLIServeResponseCache, refreshInterval: TimeInterval, - makeResponse: () async -> CLILocalHTTPResponse) async -> CLILocalHTTPResponse + requestTimeout: TimeInterval = CodexBarCLI.defaultServeRequestTimeout, + makeResponse: @Sendable @escaping () async -> CLILocalHTTPResponse) async -> CLILocalHTTPResponse { - let now = Date() - if let cached = await cache.response(for: key, now: now) { - return cached + switch await cache.responseOrStartFetch(for: key, now: Date()) { + case let .response(response): + return response + case .miss: + let response = await Self.serveResponseWithDeadline(seconds: requestTimeout) { + await makeResponse() + } + await cache.completeFetch( + response, + for: key, + ttl: refreshInterval, + now: Date(), + shouldCache: Self.shouldCacheServeResponse(response)) + return response } + } - let response = await makeResponse() - if Self.shouldCacheServeResponse(response) { - await cache.store(response, for: key, ttl: refreshInterval, now: now) + private static func serveResponseWithDeadline( + seconds timeout: TimeInterval, + makeResponse: @Sendable @escaping () async -> CLILocalHTTPResponse) async -> CLILocalHTTPResponse + { + let clampedTimeout = min(max(timeout, 0), 86400) + guard clampedTimeout > 0 else { + return await makeResponse() + } + let nanoseconds = max(1, UInt64((clampedTimeout * 1_000_000_000).rounded(.up))) + + return await withCheckedContinuation { continuation in + let state = CLIServeDeadlineState(continuation: continuation) + let workTask = Task { + let response = await makeResponse() + state.finish(response, cancelWork: false, cancelTimeout: true) + } + state.setWorkTask(workTask) + + let timeoutTask = Task { + do { + try await Task.sleep(nanoseconds: nanoseconds) + } catch { + return + } + state.finish( + Self.serveError(status: .gatewayTimeout, message: "request timed out"), + cancelWork: true, + cancelTimeout: false) + } + state.setTimeoutTask(timeoutTask) } - return response } static func shouldCacheServeResponse(_ response: CLILocalHTTPResponse) -> Bool { diff --git a/Sources/CodexBarCore/CopilotUsageModels.swift b/Sources/CodexBarCore/CopilotUsageModels.swift index d845624b..dc8dd1db 100644 --- a/Sources/CodexBarCore/CopilotUsageModels.swift +++ b/Sources/CodexBarCore/CopilotUsageModels.swift @@ -22,6 +22,8 @@ public struct CopilotUsageResponse: Sendable, Decodable { public let percentRemaining: Double public let quotaId: String public let hasPercentRemaining: Bool + private let entitlementWasDecoded: Bool + private let remainingWasDecoded: Bool public var usedPercent: Double { max(0, 100 - self.percentRemaining) } @@ -31,7 +33,21 @@ public struct CopilotUsageResponse: Sendable, Decodable { } public var isPlaceholder: Bool { - self.entitlement == 0 && self.remaining == 0 && self.percentRemaining == 0 && self.quotaId.isEmpty + if self.entitlement == 0, + self.remaining == 0, + self.percentRemaining == 0, + !self.hasPercentRemaining + { + return true + } + + // An explicit zero-entitlement, zero-remaining snapshot carries no usable quota signal. + // GitHub returns this shape for token-based billing / Copilot Business seats, + // sometimes as percent_remaining=100 with a non-empty quota_id, which would + // otherwise render as a misleading "0% used" (100 - 100). Treat it as a + // placeholder so the usual handling drops it instead of showing fake usage. + return self.entitlementWasDecoded && self.remainingWasDecoded && self.entitlement == 0 && self + .remaining == 0 } private enum CodingKeys: String, CodingKey { @@ -53,6 +69,8 @@ public struct CopilotUsageResponse: Sendable, Decodable { self.percentRemaining = percentRemaining self.quotaId = quotaId self.hasPercentRemaining = hasPercentRemaining + self.entitlementWasDecoded = true + self.remainingWasDecoded = true } public init(from decoder: any Decoder) throws { @@ -61,6 +79,8 @@ public struct CopilotUsageResponse: Sendable, Decodable { let decodedRemaining = Self.decodeNumberIfPresent(container: container, key: .remaining) self.entitlement = decodedEntitlement ?? 0 self.remaining = decodedRemaining ?? 0 + self.entitlementWasDecoded = decodedEntitlement != nil + self.remainingWasDecoded = decodedRemaining != nil let decodedPercent = Self.decodeNumberIfPresent(container: container, key: .percentRemaining) if let decodedPercent { self.percentRemaining = decodedPercent @@ -213,12 +233,14 @@ public struct CopilotUsageResponse: Sendable, Decodable { public let quotaSnapshots: QuotaSnapshots public let copilotPlan: String + public let tokenBasedBilling: Bool public let assignedDate: String? public let quotaResetDate: String? private enum CodingKeys: String, CodingKey { case quotaSnapshots = "quota_snapshots" case copilotPlan = "copilot_plan" + case tokenBasedBilling = "token_based_billing" case assignedDate = "assigned_date" case quotaResetDate = "quota_reset_date" case monthlyQuotas = "monthly_quotas" @@ -228,11 +250,13 @@ public struct CopilotUsageResponse: Sendable, Decodable { public init( quotaSnapshots: QuotaSnapshots, copilotPlan: String, + tokenBasedBilling: Bool = false, assignedDate: String?, quotaResetDate: String?) { self.quotaSnapshots = quotaSnapshots self.copilotPlan = copilotPlan + self.tokenBasedBilling = tokenBasedBilling self.assignedDate = assignedDate self.quotaResetDate = quotaResetDate } @@ -253,6 +277,7 @@ public struct CopilotUsageResponse: Sendable, Decodable { self.quotaSnapshots = directSnapshots ?? QuotaSnapshots(premiumInteractions: nil, chat: nil) } self.copilotPlan = try container.decodeIfPresent(String.self, forKey: .copilotPlan) ?? "unknown" + self.tokenBasedBilling = try container.decodeIfPresent(Bool.self, forKey: .tokenBasedBilling) ?? false self.assignedDate = try container.decodeIfPresent(String.self, forKey: .assignedDate) self.quotaResetDate = try container.decodeIfPresent(String.self, forKey: .quotaResetDate) } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index fa30ac9d..0dd1a79d 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -18,7 +18,27 @@ public enum CostUsageError: LocalizedError, Sendable { } public struct CostUsageFetcher: Sendable { - public init() {} + private let scannerOptions: CostUsageScanner.Options? + + public init(cacheRoot: URL? = nil) { + self.scannerOptions = cacheRoot.map { CostUsageScanner.Options(cacheRoot: $0) } + } + + init(scannerOptions: CostUsageScanner.Options) { + self.scannerOptions = scannerOptions + } + + public func loadCachedCodexTokenSnapshot( + now: Date = Date(), + codexHomePath: String? = nil, + historyDays: Int = 30) async -> CostUsageTokenSnapshot? + { + await Self.loadCachedCodexTokenSnapshot( + now: now, + codexHomePath: codexHomePath, + historyDays: historyDays, + scannerOptions: self.scannerOptionsOverride()) + } public func loadTokenSnapshot( provider: UsageProvider, @@ -31,6 +51,30 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: Bool = true) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( + provider: provider, + environment: environment, + now: now, + forceRefresh: forceRefresh, + allowVertexClaudeFallback: allowVertexClaudeFallback, + codexHomePath: codexHomePath, + historyDays: historyDays, + refreshPricingInBackground: refreshPricingInBackground, + scannerOptions: self.scannerOptionsOverride()) + } + + @available(*, deprecated, message: "Codex token-cost scans are uncapped; this limit is ignored.") + public func loadTokenSnapshot( + provider: UsageProvider, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date(), + forceRefresh: Bool = false, + allowVertexClaudeFallback: Bool = false, + codexHomePath: String? = nil, + historyDays: Int = 30, + refreshPricingInBackground: Bool = true, + automaticCodexScanByteLimit _: Int64?) async throws -> CostUsageTokenSnapshot + { + try await self.loadTokenSnapshot( provider: provider, environment: environment, now: now, @@ -41,6 +85,10 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: refreshPricingInBackground) } + private func scannerOptionsOverride() -> CostUsageScanner.Options? { + self.scannerOptions + } + static func loadTokenSnapshot( provider: UsageProvider, environment: [String: String] = ProcessInfo.processInfo.environment, @@ -150,6 +198,58 @@ public struct CostUsageFetcher: Sendable { return Self.tokenSnapshot(from: daily, now: now, historyDays: clampedHistoryDays) } + static func loadCachedCodexTokenSnapshot( + now: Date = Date(), + codexHomePath: String? = nil, + historyDays: Int = 30, + scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CostUsageTokenSnapshot? + { + if let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !codexHomePath.isEmpty + { + return nil + } + + return await Task.detached(priority: .utility) { + let clampedHistoryDays = max(1, min(365, historyDays)) + let until = now + let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let options = overrideScannerOptions ?? CostUsageScanner.Options() + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) + var reports: [CostUsageDailyReport] = [] + + if !cache.days.isEmpty, + cache.roots == CostUsageScanner.codexRootsFingerprint(options: options), + !CostUsageScanner.requestedWindowExpandsCache(range: range, cache: cache) + { + let daily = CostUsageScanner.buildCodexReportFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: options.cacheRoot) + if !daily.data.isEmpty { + reports.append(daily) + } + } + + if let piDaily = PiSessionCostScanner.loadCachedDailyReport( + provider: .codex, + since: since, + until: until, + now: now, + cacheRoot: options.cacheRoot) + { + reports.append(piDaily) + } + + guard !reports.isEmpty else { return nil } + return Self.tokenSnapshot( + from: CostUsageDailyReport.merged(reports), + now: now, + historyDays: clampedHistoryDays) + }.value + } + private static func loadBedrockDailyReport( environment: [String: String], since: Date, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 5b14098c..1ab81023 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "85d054bc70dae105" + static let value = "518924b891f96a03" } diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index 405199c0..a9a935b4 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -101,11 +101,12 @@ public enum KeychainCacheStore { return } - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, ] + KeychainNoUIQuery.apply(to: &query) let updateStatus = SecItemUpdate( query as CFDictionary, @@ -140,20 +141,16 @@ public enum KeychainCacheStore { } guard self.canUseRealKeychain else { return false } #if os(macOS) - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, ] - let status = SecItemDelete(query as CFDictionary) - if status == errSecSuccess { - return true - } - if status != errSecItemNotFound { - self.log.error("Keychain cache delete failed (\(key.account)): \(status)") - } - #endif + KeychainNoUIQuery.apply(to: &query) + return self.clearResultForKeychainDeleteStatus(SecItemDelete(query as CFDictionary), key: key) + #else return false + #endif } public static func keys(category: String) -> [Key] { @@ -232,6 +229,10 @@ public enum KeychainCacheStore { self.canUseRealKeychain } + static var canEnumerateOrDeleteRealKeychainForTesting: Bool { + self.canUseRealKeychain + } + #if DEBUG && os(macOS) public static func withLoadFailureStatusOverrideForTesting( _ status: OSStatus?, @@ -314,6 +315,21 @@ public enum KeychainCacheStore { } } + static func clearResultForKeychainDeleteStatus(_ status: OSStatus, key: Key) -> Bool { + switch status { + case errSecSuccess: + return true + case errSecItemNotFound: + return false + case errSecInteractionNotAllowed: + self.log.info("Keychain cache delete temporarily unavailable (\(key.account))") + return false + default: + self.log.error("Keychain cache delete failed (\(key.account)): \(status)") + return false + } + } + static func trustedApplicationPathsForCacheAccess( bundleURL: URL = Bundle.main.bundleURL, executableURL: URL? = Bundle.main.executableURL, diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 58bb04db..c07871cb 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -198,10 +198,16 @@ public struct OpenAIDashboardFetcher { return true } + private nonisolated static func sleepForDashboardPoll(_ duration: Duration) async throws { + try? await Task.sleep(for: duration) + try Task.checkCancellation() + } + public func loadLatestDashboard( accountEmail: String?, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, + allowNavigationTimeoutRetry: Bool = true, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) @@ -209,6 +215,7 @@ public struct OpenAIDashboardFetcher { websiteDataStore: store, logger: logger, debugDumpHTML: debugDumpHTML, + allowNavigationTimeoutRetry: allowNavigationTimeoutRetry, timeout: timeout) } @@ -216,19 +223,21 @@ public struct OpenAIDashboardFetcher { websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, + allowNavigationTimeoutRetry: Bool = true, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { let deadline = Self.deadline(startingAt: Date(), timeout: timeout) let preflight = await Self.fetchDashboardAPIPreflight( websiteDataStore: websiteDataStore, logger: { logger?($0) }) - let apiData = preflight.apiData - let verifiedSignedInEmail = preflight.verifiedSignedInEmail + try Task.checkCancellation() + let (apiData, verifiedSignedInEmail) = (preflight.apiData, preflight.verifiedSignedInEmail) let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, logger: logger, - timeout: Self.remainingTimeout(until: deadline)) + timeout: Self.remainingTimeout(until: deadline), + allowNavigationTimeoutRetry: allowNavigationTimeoutRetry) defer { lease.release() } let webView = lease.webView let log = lease.log @@ -244,6 +253,7 @@ public struct OpenAIDashboardFetcher { var lastUsageBreakdownError: String? var lastCreditsPurchaseURL: String? while Date() < deadline { + try Task.checkCancellation() let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody @@ -261,25 +271,23 @@ public struct OpenAIDashboardFetcher { } if scrape.workspacePicker { - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } + try await self.handleBlockingScrapeState( + scrape, + webView: webView, + debugDumpHTML: debugDumpHTML, + logger: log) + // The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL. - if let href = scrape.href, !Self.isUsageRoute(href) { + if Self.shouldReloadUsageRoute(scrape) { _ = webView.load(Self.usageURLRequest(url: self.usageURL)) - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } - if debugDumpHTML, - scrape.loginRequired || scrape.cloudflareInterstitial, - let html = try? await self.fetchDebugHTML(webView: webView) - { - Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) - } - try Self.throwIfBlockingScrapeState(scrape) - let dashboardData = Self.parseDashboardScrape( scrape, apiData: apiData, @@ -321,7 +329,7 @@ public struct OpenAIDashboardFetcher { "rows=\(scrape.rows.count)") if scrape.didScrollToCredits { log("scrollIntoView(Credits usage history) requested; waiting…") - try? await Task.sleep(for: .milliseconds(600)) + try await Self.sleepForDashboardPoll(.milliseconds(600)) continue } @@ -338,7 +346,7 @@ public struct OpenAIDashboardFetcher { creditsHeaderInViewport: scrape.creditsHeaderInViewport, didScrollToCredits: scrape.didScrollToCredits)) { - try? await Task.sleep(for: .milliseconds(400)) + try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } } @@ -350,7 +358,7 @@ public struct OpenAIDashboardFetcher { now: Date(), errorFirstSeenAt: usageBreakdownErrorFirstSeenAt)) { - try? await Task.sleep(for: .milliseconds(400)) + try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } @@ -359,7 +367,7 @@ public struct OpenAIDashboardFetcher { if codeReview != nil, usageBreakdown.isEmpty { let elapsed = Date().timeIntervalSince(codeReviewFirstSeenAt ?? Date()) if elapsed < 6 { - try? await Task.sleep(for: .milliseconds(400)) + try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } } @@ -377,7 +385,7 @@ public struct OpenAIDashboardFetcher { accountPlan: dashboardData.accountPlan)) } - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) } if debugDumpHTML, let html = try? await self.fetchDebugHTML(webView: webView) { @@ -435,28 +443,27 @@ public struct OpenAIDashboardFetcher { var dashboardSignalSeenAt: Date? while Date() < deadline { + try Task.checkCancellation() let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody lastHref = scrape.href ?? lastHref if scrape.workspacePicker { - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } - if let href = scrape.href, !Self.isUsageRoute(href) { + Self.logBlockingStateIfNeeded(scrape, logger: log) + try Self.throwIfBlockingScrapeState(scrape) + + if Self.shouldReloadUsageRoute(scrape) { usageRouteSeenAt = nil dashboardSignalSeenAt = nil _ = webView.load(Self.usageURLRequest(url: self.usageURL)) - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } - if scrape.loginRequired { throw FetchError.loginRequired } - if scrape.cloudflareInterstitial { - throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") - } - let normalizedEmail = scrape.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let bodyText = scrape.bodyText ?? "" let rateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText) @@ -482,7 +489,7 @@ public struct OpenAIDashboardFetcher { signedInEmail: normalizedEmail, hasDashboardSignal: hasDashboardSignal)) { - try? await Task.sleep(for: .milliseconds(400)) + try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } @@ -630,6 +637,7 @@ public struct OpenAIDashboardFetcher { websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)?, timeout: TimeInterval, + allowNavigationTimeoutRetry: Bool = true, preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease { try await OpenAIDashboardWebViewCache.shared.acquire( @@ -637,6 +645,7 @@ public struct OpenAIDashboardFetcher { usageURL: self.usageURL, logger: logger, navigationTimeout: timeout, + allowTimeoutRetry: allowNavigationTimeoutRetry, preserveLoadedPageOnRelease: preserveLoadedPageOnRelease) } @@ -663,6 +672,25 @@ public struct OpenAIDashboardFetcher { || path.hasSuffix("codex/cloud/settings/analytics") } + nonisolated static func shouldReloadUsageRoute( + href: String?, + loginRequired: Bool, + workspacePicker: Bool, + cloudflareInterstitial: Bool) -> Bool + { + guard !workspacePicker, !loginRequired, !cloudflareInterstitial else { return false } + guard let href else { return false } + return !self.isUsageRoute(href) + } + + private nonisolated static func shouldReloadUsageRoute(_ scrape: ScrapeResult) -> Bool { + self.shouldReloadUsageRoute( + href: scrape.href, + loginRequired: scrape.loginRequired, + workspacePicker: scrape.workspacePicker, + cloudflareInterstitial: scrape.cloudflareInterstitial) + } + nonisolated static func usageURLRequest(url: URL) -> URLRequest { var request = URLRequest(url: url) request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") @@ -948,6 +976,32 @@ extension OpenAIDashboardFetcher { logger("usage breakdown error: \(error)") } } + +extension OpenAIDashboardFetcher { + private static func logBlockingStateIfNeeded(_ scrape: ScrapeResult, logger: (String) -> Void) { + guard scrape.loginRequired || scrape.cloudflareInterstitial else { return } + let route = self.isUsageRoute(scrape.href) ? "usage" : "other" + logger( + "blocking state before route reload route=\(route) " + + "login=\(scrape.loginRequired) cloudflare=\(scrape.cloudflareInterstitial)") + } + + private func handleBlockingScrapeState( + _ scrape: ScrapeResult, + webView: WKWebView, + debugDumpHTML: Bool, + logger: (String) -> Void) async throws + { + if debugDumpHTML, + scrape.loginRequired || scrape.cloudflareInterstitial, + let html = try? await self.fetchDebugHTML(webView: webView) + { + Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: logger) + } + Self.logBlockingStateIfNeeded(scrape, logger: logger) + try Self.throwIfBlockingScrapeState(scrape) + } +} #else import Foundation @@ -973,6 +1027,7 @@ public struct OpenAIDashboardFetcher { accountEmail _: String?, logger _: ((String) -> Void)? = nil, debugDumpHTML _: Bool = false, + allowNavigationTimeoutRetry _: Bool = true, timeout _: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { throw FetchError.noDashboardData(body: "OpenAI web dashboard fetch is only supported on macOS.") diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift index 59f3f1ef..71626669 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift @@ -29,6 +29,10 @@ final class NavigationDelegate: NSObject, WKNavigationDelegate { DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } + func cancel() { + self.completeOnce(.failure(CancellationError())) + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { self.completeOnce(.success(())) } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index 1e9a91d3..116731b3 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -28,6 +28,34 @@ final class OpenAIDashboardWebViewCache { let preserveLoadedPageOnRelease: Bool } + @MainActor + private final class NavigationCancellationState { + private weak var webView: WKWebView? + private var delegate: NavigationDelegate? + private var isCancelled = false + + func install(webView: WKWebView, delegate: NavigationDelegate) { + self.webView = webView + self.delegate = delegate + if self.isCancelled { + self.cancel() + } + } + + func cancel() { + self.isCancelled = true + guard let webView, let delegate else { return } + delegate.cancel() + if webView.codexNavigationDelegate === delegate { + webView.stopLoading() + webView.navigationDelegate = nil + webView.codexNavigationDelegate = nil + } + self.delegate = nil + self.webView = nil + } + } + private final class Entry { let webView: WKWebView let host: OffscreenWebViewHost @@ -237,6 +265,7 @@ final class OpenAIDashboardWebViewCache { usageURL: URL, logger: ((String) -> Void)?, navigationTimeout: TimeInterval = 15, + allowTimeoutRetry: Bool = true, preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease { let deadline = Date().addingTimeInterval(max(navigationTimeout, 1)) @@ -246,7 +275,7 @@ final class OpenAIDashboardWebViewCache { logger: logger, deadline: deadline, options: .init( - allowTimeoutRetry: true, + allowTimeoutRetry: allowTimeoutRetry, preserveLoadedPageOnRelease: preserveLoadedPageOnRelease)) } @@ -497,14 +526,26 @@ final class OpenAIDashboardWebViewCache { Self.log.debug("OpenAI preserved page reset failed; reloading usage URL") } - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - let delegate = NavigationDelegate { result in - cont.resume(with: result) + try Task.checkCancellation() + let cancellationState = NavigationCancellationState() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let delegate = NavigationDelegate { result in + cont.resume(with: result) + } + webView.navigationDelegate = delegate + webView.codexNavigationDelegate = delegate + cancellationState.install(webView: webView, delegate: delegate) + delegate.armTimeout(seconds: timeout) + _ = webView.load(OpenAIDashboardFetcher.usageURLRequest(url: usageURL)) + if Task.isCancelled { + cancellationState.cancel() + } + } + } onCancel: { + Task { @MainActor in + cancellationState.cancel() } - webView.navigationDelegate = delegate - webView.codexNavigationDelegate = delegate - delegate.armTimeout(seconds: timeout) - _ = webView.load(OpenAIDashboardFetcher.usageURLRequest(url: usageURL)) } } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index d6d9f1e8..a1aebbda 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -133,6 +133,31 @@ enum PiSessionCostScanner { pricingContext: pricingContext) } + static func loadCachedDailyReport( + provider: UsageProvider, + since: Date, + until: Date, + now: Date = Date(), + cacheRoot: URL? = nil) -> CostUsageDailyReport? + { + guard provider == .codex || provider == .claude else { return nil } + + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let cache = PiSessionCostCacheIO.load(cacheRoot: cacheRoot) + guard !cache.daysByProvider.isEmpty else { return nil } + guard !self.requestedWindowExpandsCache(range: range, cache: cache) else { return nil } + + let pricingContext = ModelsDevPricingContext( + catalog: CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: cacheRoot), + cacheRoot: cacheRoot) + let report = self.buildReport( + provider: provider, + cache: cache, + range: range, + pricingContext: pricingContext) + return report.data.isEmpty ? nil : report + } + private static func requestedWindowExpandsCache( range: CostUsageScanner.CostUsageDayRange, cache: PiSessionCostCache) -> Bool diff --git a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift index dbe39d4c..e80e48cf 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift @@ -371,13 +371,18 @@ public struct AmpUsageFetcher: Sendable { } static func shouldAttachCookie(to url: URL?) -> Bool { + guard url?.scheme?.lowercased() == "https" else { return false } + return self.isAmpHost(url) + } + + private static func isAmpHost(_ url: URL?) -> Bool { guard let host = url?.host?.lowercased() else { return false } if host == "ampcode.com" || host == "www.ampcode.com" { return true } return host.hasSuffix(".ampcode.com") } static func isLoginRedirect(_ url: URL) -> Bool { - guard self.shouldAttachCookie(to: url) else { return false } + guard self.isAmpHost(url) else { return false } let path = url.path.lowercased() let components = path.split(separator: "/").map(String.init) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift index cb1dadad..84524b18 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift @@ -146,7 +146,8 @@ public struct AntigravityRemoteUsageFetcher: Sendable { return AntigravityStatusSnapshot( modelQuotas: models, accountEmail: claims.email, - accountPlan: Self.resolvePlan(response: codeAssist, claims: claims)) + accountPlan: Self.resolvePlan(response: codeAssist, claims: claims), + source: .remote) } private static func shouldRefresh(expiryDate: Date?, now: Date) -> Bool { diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index cd84afef..e1d3bbb7 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -37,25 +37,48 @@ private enum AntigravityModelFamily { case unknown } +private struct AntigravityModelVersion: Comparable { + let major: Int + let minor: Int + + static func < (lhs: AntigravityModelVersion, rhs: AntigravityModelVersion) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + return lhs.minor < rhs.minor + } +} + private struct AntigravityNormalizedModel { let quota: AntigravityModelQuota let family: AntigravityModelFamily let selectionPriority: Int? + let isImage: Bool + let isLite: Bool + let isAutocomplete: Bool + let version: AntigravityModelVersion? + let tier: Int +} + +public enum AntigravityModelQuotaSource: Sendable { + case local + case remote } public struct AntigravityStatusSnapshot: Sendable { public let modelQuotas: [AntigravityModelQuota] public let accountEmail: String? public let accountPlan: String? + public let source: AntigravityModelQuotaSource public init( modelQuotas: [AntigravityModelQuota], accountEmail: String?, - accountPlan: String?) + accountPlan: String?, + source: AntigravityModelQuotaSource = .remote) { self.modelQuotas = modelQuotas self.accountEmail = accountEmail self.accountPlan = accountPlan + self.source = source } public func toUsageSnapshot() throws -> UsageSnapshot { @@ -64,13 +87,19 @@ public struct AntigravityStatusSnapshot: Sendable { } let normalized = Self.normalizedModels(self.modelQuotas) - let primaryQuota = Self.representative(for: .claude, in: normalized) - let secondaryQuota = Self.representative(for: .geminiPro, in: normalized) - let tertiaryQuota = Self.representative(for: .geminiFlash, in: normalized) + let summaryModels: [AntigravityNormalizedModel] = switch self.source { + case .local: + normalized + case .remote: + normalized.filter(Self.isRemoteSummaryCandidate) + } + let primaryQuota = Self.representative(for: .claude, in: summaryModels) + let secondaryQuota = Self.representative(for: .geminiPro, in: summaryModels) + let tertiaryQuota = Self.representative(for: .geminiFlash, in: summaryModels) let fallbackQuota: AntigravityModelQuota? = if primaryQuota == nil, secondaryQuota == nil, tertiaryQuota == nil { - Self.fallbackRepresentative(in: normalized) + Self.fallbackRepresentative(in: summaryModels) } else { nil } @@ -80,12 +109,21 @@ public struct AntigravityStatusSnapshot: Sendable { let tertiary = tertiaryQuota.map(Self.rateWindow(for:)) // primary/secondary/tertiary keep the 3-family summary for back-compat. - // extraRateWindows carries every model quota loss-free, including families - // with no dedicated slot and variants the representative selection collapses. - let extraWindows = self.modelQuotas - .sorted(by: Self.modelQuotaSortPrecedes) - .map { quota in - NamedRateWindow(id: quota.modelId, title: quota.label, window: Self.rateWindow(for: quota)) + // extraRateWindows carries a source-aware set: the full curated list for + // .local (verified junk-free), and a filtered list for .remote (catalog noise + // hidden, consumed quota always kept). Sorted by family→version→tier. + let shownModels: [AntigravityNormalizedModel] = switch self.source { + case .local: + normalized + case .remote: + normalized.filter { m in + Self.isRemoteSummaryCandidate(m) || (m.quota.remainingFraction ?? 1.0) < 0.999 + } + } + let extraWindows = shownModels + .sorted(by: Self.modelOrderPrecedes) + .map { m in + NamedRateWindow(id: m.quota.modelId, title: m.quota.label, window: Self.rateWindow(for: m.quota)) } let identity = ProviderIdentitySnapshot( @@ -110,12 +148,51 @@ public struct AntigravityStatusSnapshot: Sendable { resetDescription: quota.resetDescription) } - private static func modelQuotaSortPrecedes(_ lhs: AntigravityModelQuota, _ rhs: AntigravityModelQuota) -> Bool { - let labelOrder = lhs.label.caseInsensitiveCompare(rhs.label) - if labelOrder != .orderedSame { - return labelOrder == .orderedAscending + private static func modelOrderPrecedes( + _ lhs: AntigravityNormalizedModel, + _ rhs: AntigravityNormalizedModel) -> Bool + { + // 1. Family rank: claude=0, geminiPro=1, geminiFlash=2, unknown=3 + let lhsFamilyRank = Self.familyRank(lhs.family) + let rhsFamilyRank = Self.familyRank(rhs.family) + if lhsFamilyRank != rhsFamilyRank { + return lhsFamilyRank < rhsFamilyRank + } + + // 2. Version descending (newer first); nil version sorts after non-nil + switch (lhs.version, rhs.version) { + case let (.some(lv), .some(rv)): + if lv != rv { + return lv > rv + } + case (.some, .none): + return true + case (.none, .some): + return false + case (.none, .none): + break + } + + // 3. Tier ascending: High(0) < Medium(1) < Low(2) + if lhs.tier != rhs.tier { + return lhs.tier < rhs.tier + } + + // 4. Label tiebreaker + return lhs.quota.label.localizedCaseInsensitiveCompare(rhs.quota.label) == .orderedAscending + } + + private static func familyRank(_ family: AntigravityModelFamily) -> Int { + switch family { + case .claude: 0 + case .geminiPro: 1 + case .geminiFlash: 2 + case .unknown: 3 } - return lhs.modelId.caseInsensitiveCompare(rhs.modelId) == .orderedAscending + } + + private static func isRemoteSummaryCandidate(_ model: AntigravityNormalizedModel) -> Bool { + model.family != .unknown && !model.isLite && !model.isAutocomplete && !model.isImage } private static func normalizedModels(_ models: [AntigravityModelQuota]) -> [AntigravityNormalizedModel] { @@ -130,6 +207,8 @@ public struct AntigravityStatusSnapshot: Sendable { let isLite = modelId.contains("lite") || label.contains("lite") let isAutocomplete = modelId.contains("autocomplete") || label.contains("autocomplete") || modelId .hasPrefix("tab_") + let isImage = modelId.contains("image") || label.contains("image") + let isSelectableTextModel = !isLite && !isAutocomplete && !isImage let isLowPriorityGeminiPro = modelId.contains("pro-low") || (label.contains("pro") && label.contains("low")) @@ -137,23 +216,59 @@ public struct AntigravityStatusSnapshot: Sendable { case .claude: 0 case .geminiPro: - if isLowPriorityGeminiPro { + if isLowPriorityGeminiPro, isSelectableTextModel { 0 - } else if !isLite, !isAutocomplete { + } else if isSelectableTextModel { 1 } else { nil } case .geminiFlash: - (!isLite && !isAutocomplete) ? 0 : nil + isSelectableTextModel ? 0 : nil case .unknown: nil } + let version = Self.parseVersion(from: label) + let tier = Self.parseTier(from: label, modelId: modelId) + return AntigravityNormalizedModel( quota: quota, family: family, - selectionPriority: selectionPriority) + selectionPriority: selectionPriority, + isImage: isImage, + isLite: isLite, + isAutocomplete: isAutocomplete, + version: version, + tier: tier) + } + + private static func parseVersion(from label: String) -> AntigravityModelVersion? { + // Accept either "." or "-" between major and minor so a raw model id used as the + // label when displayName is missing (e.g. "gemini-3-1-pro-low") still parses 3.1. + guard let regex = try? NSRegularExpression(pattern: #"(\d+)(?:[.\-](\d+))?"#) else { return nil } + let nsLabel = label as NSString + let range = NSRange(location: 0, length: nsLabel.length) + guard let match = regex.firstMatch(in: label, options: [], range: range) else { return nil } + let majorRange = Range(match.range(at: 1), in: label) + guard let majorRange, let major = Int(label[majorRange]) else { return nil } + let minor: Int = if match.range(at: 2).location != NSNotFound, + let minorRange = Range(match.range(at: 2), in: label), + let parsed = Int(label[minorRange]) + { + parsed + } else { + 0 + } + return AntigravityModelVersion(major: major, minor: minor) + } + + private static func parseTier(from label: String, modelId: String) -> Int { + let combined = label + " " + modelId + if combined.contains("high") { return 0 } + if combined.contains("medium") { return 1 } + if combined.contains("low") { return 2 } + return 1 } private static func representative( @@ -382,7 +497,8 @@ public struct AntigravityStatusProbe: Sendable { return AntigravityStatusSnapshot( modelQuotas: models, accountEmail: email, - accountPlan: planName) + accountPlan: planName, + source: .local) } static func parsePlanInfoSummary(_ data: Data) throws -> AntigravityPlanInfoSummary? { @@ -411,7 +527,7 @@ public struct AntigravityStatusProbe: Sendable { } let modelConfigs = response.clientModelConfigs ?? [] let models = modelConfigs.compactMap(Self.quotaFromConfig(_:)) - return AntigravityStatusSnapshot(modelQuotas: models, accountEmail: nil, accountPlan: nil) + return AntigravityStatusSnapshot(modelQuotas: models, accountEmail: nil, accountPlan: nil, source: .local) } private static func quotaFromConfig(_ config: ModelConfig) -> AntigravityModelQuota? { diff --git a/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift b/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift index 19eb38e9..ddbb6de2 100644 --- a/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift +++ b/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift @@ -52,11 +52,16 @@ public struct AuggieCLIProbe: Sendable { return output } - private func parse(_ output: String) throws -> AugmentStatusSnapshot { - // Parse output like: + func parse(_ output: String) throws -> AugmentStatusSnapshot { + // Legacy output: // Max Plan 450,000 credits / month // 11,657 remaining · 953,170 / 964,827 credits used // 2 days remaining in this billing cycle (ends 1/8/2026) + // + // Current output (2026+): + // 319,054 credits remaining Max Plan + // 450,000 credits / month + // 9 days remaining in this billing cycle (ends 6/9/2026) var maxCredits: Int? var remaining: Int? @@ -67,8 +72,15 @@ public struct AuggieCLIProbe: Sendable { for line in output.split(separator: "\n") { let trimmed = line.trimmingCharacters(in: .whitespaces) - // Parse "Max Plan 450,000 credits / month" - if trimmed.contains("Max Plan"), trimmed.contains("credits") { + if trimmed.contains("credits / month") { + if let match = trimmed.range(of: #"([\d,]+)\s+credits\s*/\s*month"#, options: .regularExpression) { + let numberStr = String(trimmed[match]).replacingOccurrences(of: ",", with: "") + .replacingOccurrences(of: " credits", with: "") + .replacingOccurrences(of: " / month", with: "") + maxCredits = Int(numberStr) + total = total ?? Int(numberStr) + } + } else if trimmed.contains("Max Plan"), trimmed.contains("credits"), !trimmed.contains("remaining") { if let match = trimmed.range(of: #"([\d,]+)\s+credits"#, options: .regularExpression) { let numberStr = String(trimmed[match]).replacingOccurrences(of: ",", with: "") .replacingOccurrences(of: " credits", with: "") @@ -76,9 +88,17 @@ public struct AuggieCLIProbe: Sendable { } } + if trimmed.contains("credits remaining"), !trimmed.contains("billing cycle") { + if let match = trimmed.range(of: #"([\d,]+)\s+credits\s+remaining"#, options: .regularExpression) { + let numberStr = String(trimmed[match]).replacingOccurrences(of: ",", with: "") + .replacingOccurrences(of: " credits", with: "") + .replacingOccurrences(of: " remaining", with: "") + remaining = Int(numberStr) + } + } + // Parse "11,657 remaining · 953,170 / 964,827 credits used" if trimmed.contains("remaining"), trimmed.contains("credits used") { - // Extract remaining if let remMatch = trimmed.range(of: #"([\d,]+)\s+remaining"#, options: .regularExpression) { let numStr = String(trimmed[remMatch]) .replacingOccurrences(of: ",", with: "") @@ -86,7 +106,6 @@ public struct AuggieCLIProbe: Sendable { remaining = Int(numStr) } - // Extract used / total if let usedMatch = trimmed.range( of: #"([\d,]+)\s*/\s*([\d,]+)\s+credits used"#, options: .regularExpression) @@ -103,15 +122,12 @@ public struct AuggieCLIProbe: Sendable { } } - // Parse "2 days remaining in this billing cycle (ends 1/8/2026)" if trimmed.contains("billing cycle"), trimmed.contains("ends") { - // Extract date from "(ends 1/8/2026)" if let dateMatch = trimmed.range(of: #"ends\s+([\d/]+)"#, options: .regularExpression) { let dateStr = String(trimmed[dateMatch]) .replacingOccurrences(of: "ends", with: "") .trimmingCharacters(in: .whitespaces) - // Parse date like "1/8/2026" let formatter = DateFormatter() formatter.dateFormat = "M/d/yyyy" formatter.locale = Locale(identifier: "en_US_POSIX") @@ -121,11 +137,19 @@ public struct AuggieCLIProbe: Sendable { } } - guard let finalRemaining = remaining, let finalUsed = used, let finalTotal = total else { + guard let finalRemaining = remaining else { Self.log.error("Failed to parse auggie output: \(output)") throw AuggieCLIError.parseError("Could not extract credits from output") } + let finalTotal = total ?? maxCredits + guard let finalTotal else { + Self.log.error("Failed to parse auggie output: \(output)") + throw AuggieCLIError.parseError("Could not extract credits from output") + } + + let finalUsed = used ?? max(0, finalTotal - finalRemaining) + return AugmentStatusSnapshot( creditsRemaining: Double(finalRemaining), creditsUsed: Double(finalUsed), diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift index 4ccb0987..7c259d4d 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift @@ -95,10 +95,8 @@ struct AugmentCLIFetchStrategy: ProviderFetchStrategy { // Fallback to web if CLI fails (not authenticated, etc.) if let cliError = error as? AuggieCLIError { switch cliError { - case .notAuthenticated, .noOutput: + case .notAuthenticated, .noOutput, .parseError: return true - case .parseError: - return false // Don't fallback on parse errors - something is wrong } } return true diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift b/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift index 40e1974a..7ffa2aea 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift @@ -212,6 +212,12 @@ public final class AugmentSessionKeepalive { try await Task.sleep(for: .seconds(1)) // Brief delay for browser to update cookies let newSession = try AugmentCookieImporter.importSession(logger: self.logger) + await AugmentSessionStore.shared.setCookies(newSession.cookies) + CookieHeaderCache.store( + provider: .augment, + cookieHeader: newSession.cookieHeader, + sourceLabel: newSession.sourceLabel) + self.log( "✅ Session refresh successful - imported \(newSession.cookies.count) cookies " + "from \(newSession.sourceLabel)") @@ -220,6 +226,11 @@ public final class AugmentSessionKeepalive { // Reset failure tracking on success self.consecutiveFailures = 0 self.hasGivenUp = false + + if let callback = self.onSessionRecovered { + self.log("🔄 Triggering usage refresh after session refresh") + await callback() + } } else { self.log("⚠️ Session refresh returned no new cookies") self.consecutiveFailures += 1 diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift index cff8957a..52ae33ae 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift @@ -15,12 +15,15 @@ public enum AugmentCookieImporter { /// NOTE: This list may not be exhaustive. If authentication fails with cookies present, /// check debug logs for cookie names and report them. private static let sessionCookieNames: Set = [ - "_session", // Legacy session cookie + "session", // Augment auth session (auth.augmentcode.com) + "_session", // Legacy session cookie (app.augmentcode.com) + "web_rpc_proxy_session", // Augment RPC proxy session "auth0", // Auth0 session "auth0.is.authenticated", // Auth0 authentication flag "a0.spajs.txs", // Auth0 SPA transaction state "__Secure-next-auth.session-token", // NextAuth secure session "next-auth.session-token", // NextAuth session + "__Secure-authjs.session-token", // AuthJS secure session "__Host-authjs.csrf-token", // AuthJS CSRF token "authjs.session-token", // AuthJS session ] diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 798fb98d..5f790ee9 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -192,17 +192,19 @@ public enum ClaudeOAuthCredentialsStore { Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration, !cachedRecord.credentials.isExpired { - if recovery.shouldAttemptFreshnessSyncFromClaudeKeychain(cached: cachedRecord), + let owner = self.resolvedCacheOwner(cachedRecord.owner) + let record = ClaudeOAuthCredentialRecord( + credentials: cachedRecord.credentials, + owner: owner, + source: .memoryCache) + if recovery.shouldAttemptFreshnessSyncFromClaudeKeychain(cached: record), let synced = recovery.syncWithClaudeKeychainIfChanged( - cached: cachedRecord, + cached: record, respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes) { return synced } - return ClaudeOAuthCredentialRecord( - credentials: cachedRecord.credentials, - owner: cachedRecord.owner, - source: .memoryCache) + return record } var lastError: Error? @@ -212,7 +214,7 @@ public enum ClaudeOAuthCredentialsStore { switch KeychainCacheStore.load(key: ClaudeOAuthCredentialsStore.cacheKey, as: CacheEntry.self) { case let .found(entry): if let creds = try? ClaudeOAuthCredentials.parse(data: entry.data) { - let owner = entry.owner ?? .claudeCLI + let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI) let record = ClaudeOAuthCredentialRecord( credentials: creds, owner: owner, @@ -326,9 +328,10 @@ public enum ClaudeOAuthCredentialsStore { Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration, !cachedRecord.credentials.isExpired { + let owner = self.resolvedCacheOwner(cachedRecord.owner) return ClaudeOAuthCredentialRecord( credentials: cachedRecord.credentials, - owner: cachedRecord.owner, + owner: owner, source: .memoryCache) } if case let .found(entry) = KeychainCacheStore.load( @@ -337,9 +340,10 @@ public enum ClaudeOAuthCredentialsStore { let creds = try? ClaudeOAuthCredentials.parse(data: entry.data), !creds.isExpired { + let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI) return ClaudeOAuthCredentialRecord( credentials: creds, - owner: entry.owner ?? .claudeCLI, + owner: owner, source: .cacheKeychain) } @@ -429,6 +433,18 @@ public enum ClaudeOAuthCredentialsStore { return nil } + private func resolvedCacheOwner(_ owner: ClaudeOAuthCredentialOwner) -> ClaudeOAuthCredentialOwner { + guard owner == .codexbar else { return owner } + guard self.hasClaudeCLIStorageWithoutPrompt() else { return owner } + // Claude Code rotates refresh tokens; when its storage exists, it owns the refresh lifecycle. + return .claudeCLI + } + + private func hasClaudeCLIStorageWithoutPrompt() -> Bool { + if ClaudeOAuthCredentialsStore.currentFileFingerprint() != nil { return true } + return ClaudeOAuthCredentialsStore.hasClaudeKeychainItemWithoutPrompt() + } + @discardableResult func invalidateCacheIfCredentialsFileChanged() -> Bool { self.context.run { @@ -1289,6 +1305,40 @@ public enum ClaudeOAuthCredentialsStore { Repository(context: self.currentCollaboratorContext()).hasClaudeKeychainCredentialsWithoutPrompt() } + private static func hasClaudeKeychainItemWithoutPrompt() -> Bool { + #if DEBUG + if let store = self.taskClaudeKeychainOverrideStore { + if let data = store.data, !data.isEmpty { return true } + if store.fingerprint != nil { return true } + } + if let data = self.taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride, + !data.isEmpty + { + return true + } + if self.taskClaudeKeychainFingerprintOverride ?? self.claudeKeychainFingerprintOverride != nil { + return true + } + #endif + + #if os(macOS) + switch self.claudeKeychainCandidatesProbeWithoutPrompt(enforcePromptPolicy: false) { + case let .value(candidates) where !candidates.isEmpty: + return true + case .value, .unavailable: + break + } + switch self.claudeKeychainLegacyCandidateProbeWithoutPrompt(enforcePromptPolicy: false) { + case let .value(candidate): + return candidate != nil + case .unavailable: + return false + } + #else + return false + #endif + } + private static func shouldCheckClaudeKeychainChange(now: Date = Date()) -> Bool { #if DEBUG // Unit tests can supply TaskLocal overrides for the Claude keychain data/fingerprint. Those tests often run @@ -1561,12 +1611,17 @@ public enum ClaudeOAuthCredentialsStore { private static func claudeKeychainCandidatesProbeWithoutPrompt( promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference - .current()) -> ClaudeKeychainProbe<[ClaudeKeychainCandidate]> + .current(), + enforcePromptPolicy: Bool = true) -> ClaudeKeychainProbe<[ClaudeKeychainCandidate]> { - guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } - if self.isPromptPolicyApplicable, - ProviderInteractionContext.current == .background, - !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } + if enforcePromptPolicy { + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } + if self.isPromptPolicyApplicable, + ProviderInteractionContext.current == .background, + !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } + } else { + guard self.keychainAccessAllowed else { return .unavailable } + } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.claudeKeychainService, @@ -1617,12 +1672,17 @@ public enum ClaudeOAuthCredentialsStore { private static func claudeKeychainLegacyCandidateProbeWithoutPrompt( promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference - .current()) -> ClaudeKeychainProbe + .current(), + enforcePromptPolicy: Bool = true) -> ClaudeKeychainProbe { - guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } - if self.isPromptPolicyApplicable, - ProviderInteractionContext.current == .background, - !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } + if enforcePromptPolicy { + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } + if self.isPromptPolicyApplicable, + ProviderInteractionContext.current == .background, + !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } + } else { + guard self.keychainAccessAllowed else { return .unavailable } + } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.claudeKeychainService, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift index b613a54e..5b21d120 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift @@ -217,10 +217,15 @@ extension CodexWebDashboardStrategy { switch decision.disposition { case .attach: let attachedAccountEmail = CodexCLIDashboardAuthorityContext.attachmentEmail(from: input) - guard let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: attachedAccountEmail) else { + let credits = dashboard.toCreditsSnapshot() + let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: attachedAccountEmail) + ?? Self.makeCreditsOnlyUsageSnapshot( + dashboard: dashboard, + attachedAccountEmail: attachedAccountEmail, + credits: credits) + guard let usage else { throw OpenAIWebCodexError.missingUsage } - let credits = dashboard.toCreditsSnapshot() if let attachedAccountEmail { OpenAIDashboardCacheStore.save(OpenAIDashboardCache( accountEmail: attachedAccountEmail, @@ -240,6 +245,24 @@ extension CodexWebDashboardStrategy { } } + private static func makeCreditsOnlyUsageSnapshot( + dashboard: OpenAIDashboardSnapshot, + attachedAccountEmail: String?, + credits: CreditsSnapshot?) -> UsageSnapshot? + { + guard credits != nil else { return nil } + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: dashboard.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: attachedAccountEmail ?? dashboard.signedInEmail, + accountOrganization: nil, + loginMethod: dashboard.accountPlan)) + } + private struct OpenAIWebDashboardFetchResult { let dashboard: OpenAIDashboardSnapshot let routingTargetEmail: String? diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift index 8aa9b221..758168b0 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -16,10 +16,16 @@ public struct CopilotUsageFetcher: Sendable { private let token: String private let enterpriseHost: String? + private let transport: any ProviderHTTPTransport - public init(token: String, enterpriseHost: String? = nil) { + public init( + token: String, + enterpriseHost: String? = nil, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) + { self.token = token self.enterpriseHost = enterpriseHost + self.transport = transport } public static func apiHost(enterpriseHost: String?) -> String { @@ -49,7 +55,7 @@ public struct CopilotUsageFetcher: Sendable { request.setValue("token \(self.token)", forHTTPHeaderField: "Authorization") self.addCommonHeaders(to: &request) - let response = try await ProviderHTTPClient.shared.response(for: request) + let response = try await self.transport.response(for: request) if response.statusCode == 401 || response.statusCode == 403 { throw URLError(.userAuthenticationRequired) @@ -73,6 +79,11 @@ public struct CopilotUsageFetcher: Sendable { // ("Premium" for primary, "Chat" for secondary) on chat-only plans. primary = nil secondary = chatWindow + } else if usage.tokenBasedBilling { + // Copilot Business token-based billing currently exposes zero-entitlement + // placeholder quotas on this endpoint, so surface the plan without fake usage. + primary = nil + secondary = nil } else { throw URLError(.cannotDecodeRawData) } diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift index e3b1fb23..62897187 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift @@ -816,15 +816,18 @@ public struct FactoryStatusProbe: Sendable { } private let browserDetection: BrowserDetection + private let transport: any ProviderHTTPTransport public init( baseURL: URL = URL(string: "https://app.factory.ai")!, timeout: TimeInterval = 15.0, - browserDetection: BrowserDetection) + browserDetection: BrowserDetection, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) { self.baseURL = baseURL self.timeout = timeout self.browserDetection = browserDetection + self.transport = transport } /// Fetch Factory usage using browser cookies with fallback to stored session. @@ -1266,7 +1269,7 @@ public struct FactoryStatusProbe: Sendable { let data: Data let response: URLResponse do { - (data, response) = try await ProviderHTTPClient.shared.data(for: request) + (data, response) = try await self.transport.data(for: request) } catch { return nil } @@ -1303,7 +1306,7 @@ public struct FactoryStatusProbe: Sendable { request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + let (data, response) = try await self.transport.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid response") @@ -1368,7 +1371,7 @@ public struct FactoryStatusProbe: Sendable { request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + let (data, response) = try await self.transport.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid response") @@ -1502,7 +1505,7 @@ public struct FactoryStatusProbe: Sendable { } request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) - let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + let (data, response) = try await self.transport.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid WorkOS response") } @@ -1572,7 +1575,7 @@ public struct FactoryStatusProbe: Sendable { } request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) - let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + let (data, response) = try await self.transport.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid WorkOS response") } @@ -1715,11 +1718,13 @@ public struct FactoryStatusProbe: Sendable { public init( baseURL: URL = URL(string: "https://app.factory.ai")!, timeout: TimeInterval = 15.0, - browserDetection: BrowserDetection) + browserDetection: BrowserDetection, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) { _ = baseURL _ = timeout _ = browserDetection + _ = transport } public func fetch( diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index 6b58dd80..d3aaea97 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -620,6 +620,7 @@ public struct OllamaUsageFetcher: Sendable { } static func shouldAttachCookie(to url: URL?) -> Bool { + guard url?.scheme?.lowercased() == "https" else { return false } guard let host = url?.host?.lowercased() else { return false } if host == "ollama.com" || host == "www.ollama.com" { return true } return host.hasSuffix(".ollama.com") diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift index 7e13a918..35f58b44 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift @@ -73,30 +73,34 @@ enum CostUsageJsonl { while true { try checkCancellation?() - let chunk = try handle.read(upToCount: 256 * 1024) ?? Data() - if chunk.isEmpty { - flushLine() - break - } + let reachedEOF = try autoreleasepool { + let chunk = try handle.read(upToCount: 256 * 1024) ?? Data() + if chunk.isEmpty { + flushLine() + return true + } - try checkCancellation?() - bytesRead += Int64(chunk.count) - chunk.withUnsafeBytes { rawBuffer in - guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } - var segmentStart = 0 - var index = 0 - while index < rawBuffer.count { - if base[index] == 0x0A { - appendSegment(base.advanced(by: segmentStart), count: index - segmentStart) - flushLine() - segmentStart = index + 1 + try checkCancellation?() + bytesRead += Int64(chunk.count) + chunk.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } + var segmentStart = 0 + var index = 0 + while index < rawBuffer.count { + if base[index] == 0x0A { + appendSegment(base.advanced(by: segmentStart), count: index - segmentStart) + flushLine() + segmentStart = index + 1 + } + index += 1 + } + if segmentStart < rawBuffer.count { + appendSegment(base.advanced(by: segmentStart), count: rawBuffer.count - segmentStart) } - index += 1 - } - if segmentStart < rawBuffer.count { - appendSegment(base.advanced(by: segmentStart), count: rawBuffer.count - segmentStart) } + return false } + if reachedEOF { break } try checkCancellation?() } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index fdc53f60..9ee2ca2c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -275,6 +275,16 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: nil, cacheCreationInputCostPerTokenAboveThreshold: nil, cacheReadInputCostPerTokenAboveThreshold: nil), + "claude-opus-4-8": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-sonnet-4-5": ClaudePricing( inputCostPerToken: 3e-6, outputCostPerToken: 1.5e-5, @@ -353,6 +363,12 @@ enum CostUsagePricing { /// `CostUsageJsonl.swift` change vs origin/mobile-dev. /// /// History: + /// - `5` (0.32.4.1): merged upstream v0.32.0→v0.32.4 Codex cost-scanner + /// rewrite (new `CostUsageScanner+CodexFastJSON.swift`, reworked truncated-prefix + /// handling, scan-perf changes). The regenerated parser hash rolls the Codex + /// producerKey axis; this parserLogicVersion bump rolls the pricingFingerprint so + /// the Claude axis (no producerKey) also invalidates caches written by the v0.31 + /// parser and re-scans with the merged scanner. /// - `4` (0.31.0.2): merged upstream v0.29.1→v0.31.0 cost-scanner /// changes — Codex `CostUsageScanner` rewrite (Spark model lane #1195, /// reworked token attribution) and `CostUsageScanner+Claude` now threads @@ -376,7 +392,7 @@ enum CostUsagePricing { /// in `parseCodexFile`. Bumping rolls every previous version's /// cache and re-scans with the fixed parser. /// - `1` (0.23.1): initial fingerprint contract. - static let parserLogicVersion = 4 + static let parserLogicVersion = 5 /// Stable string fingerprint of the pricing tables + parser logic. /// `CostUsageCacheIO.load` compares this against the value stored diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift new file mode 100644 index 00000000..958e4509 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift @@ -0,0 +1,255 @@ +import Foundation + +extension CostUsageScanner { + static func extractJSONByteStringField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int) -> String? + { + self.extractJSONByteField(field, from: bytes, in: range, atDepth: targetDepth) { valueIndex in + guard let parsed = parseJSONByteStringRange(in: bytes, index: &valueIndex, limit: range.upperBound), + parsed.range.lowerBound < parsed.range.upperBound + else { return nil } + if parsed.hasEscapes { + return self.decodeEscapedJSONByteString(from: bytes, in: parsed.range) + } + return String(bytes: bytes[parsed.range], encoding: .utf8) + } + } + + static func extractJSONByteObjectField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int) -> Range? + { + self.extractJSONByteField(field, from: bytes, in: range, atDepth: targetDepth) { valueIndex in + self.parseJSONByteObjectRange(in: bytes, index: &valueIndex, limit: range.upperBound) + } + } + + static func extractJSONByteIntField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int) -> Int? + { + self.extractJSONByteField(field, from: bytes, in: range, atDepth: targetDepth) { valueIndex in + self.parseJSONByteInt(in: bytes, index: &valueIndex, limit: range.upperBound) + } + } + + private static func extractJSONByteField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int, + parseValue: (inout Int) -> T?) -> T? + { + var index = range.lowerBound + var depth = 0 + + while index < range.upperBound { + switch bytes[index] { + case 0x7B: // { + depth += 1 + index += 1 + case 0x7D: // } + depth -= 1 + index += 1 + case 0x22: // " + var valueIndex = index + guard let key = parseJSONByteStringRange(in: bytes, index: &valueIndex, limit: range.upperBound) + else { return nil } + index = valueIndex + guard depth == targetDepth, + !key.hasEscapes, + self.byteRange(bytes, key.range, equals: field) + else { continue } + + self.skipJSONByteWhitespace(in: bytes, index: &valueIndex, limit: range.upperBound) + guard valueIndex < range.upperBound, bytes[valueIndex] == 0x3A else { continue } // : + + valueIndex += 1 + self.skipJSONByteWhitespace(in: bytes, index: &valueIndex, limit: range.upperBound) + if let value = parseValue(&valueIndex) { + return value + } + default: + index += 1 + } + } + + return nil + } + + private static func parseJSONByteStringRange( + in bytes: UnsafeBufferPointer, + index: inout Int, + limit: Int) -> (range: Range, hasEscapes: Bool)? + { + guard index < limit, bytes[index] == 0x22 else { return nil } // " + index += 1 + let start = index + var hasEscapes = false + + while index < limit { + switch bytes[index] { + case 0x5C: // \ + hasEscapes = true + index += 2 + case 0x22: // " + let end = index + index += 1 + return (start.., + index: inout Int, + limit: Int) -> Range? + { + guard index < limit, bytes[index] == 0x7B else { return nil } // { + let start = index + var depth = 0 + + while index < limit { + switch bytes[index] { + case 0x22: // " + guard self.parseJSONByteStringRange(in: bytes, index: &index, limit: limit) != nil else { + return nil + } + case 0x7B: // { + depth += 1 + index += 1 + case 0x7D: // } + depth -= 1 + index += 1 + if depth == 0 { + return start.., + index: inout Int, + limit: Int) -> Int? + { + var sign = 1 + if index < limit, bytes[index] == 0x2D { // - + sign = -1 + index += 1 + } + + var value = 0 + var sawDigit = false + while index < limit { + let byte = bytes[index] + guard byte >= 0x30, byte <= 0x39 else { break } + sawDigit = true + let digit = Int(byte - 0x30) + let multiplied = value.multipliedReportingOverflow(by: 10) + if multiplied.overflow { return nil } + let added = multiplied.partialValue.addingReportingOverflow(digit) + if added.overflow { return nil } + value = added.partialValue + index += 1 + } + return sawDigit ? (sign == -1 ? -value : value) : nil + } + + private static func skipJSONByteWhitespace( + in bytes: UnsafeBufferPointer, + index: inout Int, + limit: Int) + { + while index < limit { + switch bytes[index] { + case 0x20, 0x09, 0x0A, 0x0D: + index += 1 + default: + return + } + } + } + + private static func decodeEscapedJSONByteString( + from bytes: UnsafeBufferPointer, + in range: Range) -> String? + { + var out: [UInt8] = [] + out.reserveCapacity(range.count) + var index = range.lowerBound + while index < range.upperBound { + let byte = bytes[index] + guard byte == 0x5C else { // \ + out.append(byte) + index += 1 + continue + } + + index += 1 + guard index < range.upperBound else { return nil } + switch bytes[index] { + case 0x22, 0x5C, 0x2F: // ", \, / + out.append(bytes[index]) + case 0x62: // b + out.append(0x08) + case 0x66: // f + out.append(0x0C) + case 0x6E: // n + out.append(0x0A) + case 0x72: // r + out.append(0x0D) + case 0x74: // t + out.append(0x09) + case 0x75: // u + return self.decodeJSONStringViaFoundation(from: bytes, in: range) + default: + return nil + } + index += 1 + } + + return String(bytes: out, encoding: .utf8) + } + + private static func decodeJSONStringViaFoundation( + from bytes: UnsafeBufferPointer, + in range: Range) -> String? + { + var data = Data([0x22]) + data.append(UnsafeBufferPointer(rebasing: bytes[range])) + data.append(0x22) + return (try? JSONSerialization.jsonObject(with: data)) as? String + } + + private static func byteRange( + _ bytes: UnsafeBufferPointer, + _ range: Range, + equals field: [UInt8]) -> Bool + { + guard range.count == field.count else { return false } + var index = range.lowerBound + var fieldIndex = 0 + while index < range.upperBound { + guard bytes[index] == field[fieldIndex] else { return false } + index += 1 + fieldIndex += 1 + } + return true + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift index 2c2f7554..dadb0903 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift @@ -17,7 +17,7 @@ extension CostUsageScanner { ?? Self.extractJSONStringField("model_name", from: infoText, atDepth: 1) } - private static func truncatedUTF8String(from bytes: Data) -> String? { + static func truncatedUTF8String(from bytes: Data) -> String? { for dropCount in 0...min(4, bytes.count) { let end = bytes.count - dropCount if let text = String(bytes: bytes.prefix(end), encoding: .utf8) { @@ -27,7 +27,7 @@ extension CostUsageScanner { return nil } - private static func extractJSONStringField( + static func extractJSONStringField( _ field: String, from text: Substring, atDepth targetDepth: Int) -> String? @@ -39,7 +39,7 @@ extension CostUsageScanner { } } - private static func extractJSONObjectField( + static func extractJSONObjectField( _ field: String, from text: Substring, atDepth targetDepth: Int) -> Substring? @@ -50,7 +50,17 @@ extension CostUsageScanner { } } - private static func extractJSONField( + static func extractJSONIntField( + _ field: String, + from text: Substring, + atDepth targetDepth: Int) -> Int? + { + self.extractJSONField(field, from: text, atDepth: targetDepth) { text, index in + Self.parseJSONInt(in: text, index: &index) + } + } + + static func extractJSONField( _ field: String, from text: Substring, atDepth targetDepth: Int, @@ -89,7 +99,7 @@ extension CostUsageScanner { return nil } - private static func parseJSONString(in text: Substring, index: inout String.Index) -> String? { + static func parseJSONString(in text: Substring, index: inout String.Index) -> String? { guard index < text.endIndex, text[index] == "\"" else { return nil } text.formIndex(after: &index) var value = "" @@ -113,7 +123,24 @@ extension CostUsageScanner { return nil } - private static func skipJSONWhitespace(in text: Substring, index: inout String.Index) { + static func parseJSONInt(in text: Substring, index: inout String.Index) -> Int? { + var sign = 1 + if index < text.endIndex, text[index] == "-" { + sign = -1 + text.formIndex(after: &index) + } + + var value = 0 + var sawDigit = false + while index < text.endIndex, let digit = text[index].wholeNumberValue { + sawDigit = true + value = (value * 10) + digit + text.formIndex(after: &index) + } + return sawDigit ? value * sign : nil + } + + static func skipJSONWhitespace(in text: Substring, index: inout String.Index) { while index < text.endIndex, text[index].isWhitespace { text.formIndex(after: &index) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 2ae8603a..9e18fb04 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -572,6 +572,10 @@ enum CostUsageScanner { return out } + static func codexRootsFingerprint(options: Options) -> [String: Int64] { + self.codexRootsFingerprint(self.codexSessionsRoots(options: options)) + } + private static func codexPricingKey(modelsDevArtifact: ModelsDevCacheArtifact?) -> String { guard let modelsDevArtifact else { let fingerprint = CostUsagePricing.codexBuiltInPricingFingerprint() @@ -932,6 +936,43 @@ enum CostUsageScanner { let forkTimestamp: String? } + private struct CodexTokenCountRecord { + let timestamp: String + let model: String? + let turnID: String? + let last: CostUsageCodexTotals? + let total: CostUsageCodexTotals? + } + + private enum CodexFastLine { + case sessionMeta(CodexSessionMetadata) + case turnContext(model: String?) + case taskStarted(turnID: String?) + case tokenCount(CodexTokenCountRecord) + } + + private static let codexJSONFieldCachedInputTokens = Array("cached_input_tokens".utf8) + private static let codexJSONFieldCacheReadInputTokens = Array("cache_read_input_tokens".utf8) + private static let codexJSONFieldForkedFromId = Array("forked_from_id".utf8) + private static let codexJSONFieldForkedFromIdCamel = Array("forkedFromId".utf8) + private static let codexJSONFieldId = Array("id".utf8) + private static let codexJSONFieldInfo = Array("info".utf8) + private static let codexJSONFieldInputTokens = Array("input_tokens".utf8) + private static let codexJSONFieldLastTokenUsage = Array("last_token_usage".utf8) + private static let codexJSONFieldModel = Array("model".utf8) + private static let codexJSONFieldModelName = Array("model_name".utf8) + private static let codexJSONFieldOutputTokens = Array("output_tokens".utf8) + private static let codexJSONFieldParentSessionId = Array("parent_session_id".utf8) + private static let codexJSONFieldParentSessionIdCamel = Array("parentSessionId".utf8) + private static let codexJSONFieldPayload = Array("payload".utf8) + private static let codexJSONFieldSessionId = Array("session_id".utf8) + private static let codexJSONFieldSessionIdCamel = Array("sessionId".utf8) + private static let codexJSONFieldTimestamp = Array("timestamp".utf8) + private static let codexJSONFieldTotalTokenUsage = Array("total_token_usage".utf8) + private static let codexJSONFieldTurnId = Array("turn_id".utf8) + private static let codexJSONFieldTurnIdCamel = Array("turnId".utf8) + private static let codexJSONFieldType = Array("type".utf8) + private static func codexForkParentId(from payload: [String: Any]?) -> String? { guard let payload else { return nil } for key in ["forked_from_id", "forkedFromId", "parent_session_id", "parentSessionId"] { @@ -944,6 +985,236 @@ enum CostUsageScanner { return nil } + private static func codexForkParentId( + from bytes: UnsafeBufferPointer, + in payloadRange: Range) -> String? + { + for key in [ + self.codexJSONFieldForkedFromId, + self.codexJSONFieldForkedFromIdCamel, + self.codexJSONFieldParentSessionId, + self.codexJSONFieldParentSessionIdCamel, + ] { + guard let value = extractJSONByteStringField(key, from: bytes, in: payloadRange, atDepth: 1)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { continue } + return value + } + return nil + } + + private static func codexTurnID(from bytes: UnsafeBufferPointer, in payloadRange: Range) -> String? { + for key in [self.codexJSONFieldTurnId, self.codexJSONFieldTurnIdCamel, self.codexJSONFieldId] { + if let value = extractJSONByteStringField(key, from: bytes, in: payloadRange, atDepth: 1), !value.isEmpty { + return value + } + } + if let infoRange = extractJSONByteObjectField(codexJSONFieldInfo, from: bytes, in: payloadRange, atDepth: 1) { + for key in [self.codexJSONFieldTurnId, self.codexJSONFieldTurnIdCamel, self.codexJSONFieldId] { + if let value = extractJSONByteStringField(key, from: bytes, in: infoRange, atDepth: 1), !value.isEmpty { + return value + } + } + } + return nil + } + + private static func codexSessionId( + from bytes: UnsafeBufferPointer, + in rootRange: Range, + payloadRange: Range?) -> String? + { + if let payloadRange { + for key in [self.codexJSONFieldSessionId, self.codexJSONFieldSessionIdCamel, self.codexJSONFieldId] { + if let value = extractJSONByteStringField(key, from: bytes, in: payloadRange, atDepth: 1), + !value.isEmpty + { + return value + } + } + } + for key in [Self.codexJSONFieldSessionId, Self.codexJSONFieldSessionIdCamel, Self.codexJSONFieldId] { + if let value = Self.extractJSONByteStringField(key, from: bytes, in: rootRange, atDepth: 1), + !value.isEmpty + { + return value + } + } + return nil + } + + private static func codexTotals( + from bytes: UnsafeBufferPointer, + in objectRange: Range?) -> CostUsageCodexTotals? + { + guard let objectRange else { return nil } + let input = max( + 0, + Self.extractJSONByteIntField(Self.codexJSONFieldInputTokens, from: bytes, in: objectRange, atDepth: 1) ?? 0) + let cached = max( + 0, + Self.extractJSONByteIntField(Self.codexJSONFieldCachedInputTokens, from: bytes, in: objectRange, atDepth: 1) + ?? Self.extractJSONByteIntField( + Self.codexJSONFieldCacheReadInputTokens, + from: bytes, + in: objectRange, + atDepth: 1) + ?? 0) + let output = max( + 0, + Self + .extractJSONByteIntField(Self.codexJSONFieldOutputTokens, from: bytes, in: objectRange, atDepth: 1) ?? + 0) + return CostUsageCodexTotals(input: input, cached: cached, output: output) + } + + private static func parseCodexFastLine(_ bytes: Data) -> CodexFastLine? { + bytes.withUnsafeBytes { rawBytes in + let rawBuffer = rawBytes.bindMemory(to: UInt8.self) + guard !rawBuffer.isEmpty else { return nil } + let objectRange = 0.. String? @@ -971,6 +1242,9 @@ enum CostUsageScanner { func parseSessionMetadata(from lineData: Data) -> CodexSessionMetadata? { guard !lineData.isEmpty else { return nil } + if case let .sessionMeta(metadata) = Self.parseCodexFastLine(lineData) { + return metadata + } return autoreleasepool { guard let obj = (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any] else { return nil } @@ -1041,6 +1315,62 @@ enum CostUsageScanner { return date } + func appendSnapshot(timestamp: String, last: CostUsageCodexTotals?, total: CostUsageCodexTotals?) { + if let last { + let rawDelta = last + let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) + var countedDelta = rawDelta + + if let total { + let rawTotals = total + let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: rawTotals) + if Self.codexShouldPreferTotalDelta( + rawBaseline: rawTotalsBaseline, + currentTotal: rawTotals, + totalDelta: totalDelta, + lastDelta: rawDelta, + sawDivergentTotals: sawDivergentTotals) + { + countedDelta = totalDelta + } + let next = Self.codexAddTotals(base, countedDelta) + previousTotals = next + rawTotalsBaseline = rawTotals + if !Self.codexTotalsEqual(rawTotals, next) { + sawDivergentTotals = true + } + } else { + let next = Self.codexAddTotals(base, countedDelta) + previousTotals = next + rawTotalsBaseline = next + } + + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: previousTotals ?? base)) + } else if let total { + let next = total + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: next) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: next) + let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) + let countedTotals = Self.codexAddTotals(base, delta) + previousTotals = countedTotals + rawTotalsBaseline = next + if !Self.codexTotalsEqual(next, countedTotals) { + sawDivergentTotals = true + } + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: countedTotals)) + } + } + do { _ = try CostUsageJsonl.scan( fileURL: fileURL, @@ -1049,6 +1379,20 @@ enum CostUsageScanner { checkCancellation: checkCancellation, onLine: { line in guard !line.bytes.isEmpty, !line.wasTruncated else { return } + if let fastLine = Self.parseCodexFastLine(line.bytes) { + switch fastLine { + case let .sessionMeta(metadata): + if sessionId == nil { + sessionId = metadata.sessionId + } + case let .tokenCount(record): + appendSnapshot(timestamp: record.timestamp, last: record.last, total: record.total) + case .turnContext, .taskStarted: + break + } + return + } + autoreleasepool { guard let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any] else { return } @@ -1077,71 +1421,19 @@ enum CostUsageScanner { return 0 } - let total = info["total_token_usage"] as? [String: Any] - let last = info["last_token_usage"] as? [String: Any] - - if let last { - let rawDelta = CostUsageCodexTotals( - input: max(0, toInt(last["input_tokens"])), - cached: max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"])), - output: max(0, toInt(last["output_tokens"]))) - let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) - var countedDelta = rawDelta - - if let total { - let rawTotals = CostUsageCodexTotals( - input: toInt(total["input_tokens"]), - cached: toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]), - output: toInt(total["output_tokens"])) - let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: rawTotals) - if Self.codexShouldPreferTotalDelta( - rawBaseline: rawTotalsBaseline, - currentTotal: rawTotals, - totalDelta: totalDelta, - lastDelta: rawDelta, - sawDivergentTotals: sawDivergentTotals) - { - countedDelta = totalDelta - } - let next = Self.codexAddTotals(base, countedDelta) - previousTotals = next - rawTotalsBaseline = rawTotals - if !Self.codexTotalsEqual(rawTotals, next) { - sawDivergentTotals = true - } - } else { - let next = Self.codexAddTotals(base, countedDelta) - previousTotals = next - rawTotalsBaseline = next - } - - snapshots.append(CodexTimestampedTotals( - timestamp: timestamp, - date: parsedSnapshotDate(timestamp: timestamp), - totals: previousTotals ?? base)) - } else if let total { - let next = CostUsageCodexTotals( - input: toInt(total["input_tokens"]), - cached: toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]), - output: toInt(total["output_tokens"])) - let delta = sawDivergentTotals - ? Self.codexDivergentTotalDelta( - rawBaseline: rawTotalsBaseline, - countedBaseline: previousTotals, - current: next) - : Self.codexTotalDelta(from: rawTotalsBaseline, to: next) - let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) - let countedTotals = Self.codexAddTotals(base, delta) - previousTotals = countedTotals - rawTotalsBaseline = next - if !Self.codexTotalsEqual(next, countedTotals) { - sawDivergentTotals = true - } - snapshots.append(CodexTimestampedTotals( - timestamp: timestamp, - date: parsedSnapshotDate(timestamp: timestamp), - totals: countedTotals)) + let total = (info["total_token_usage"] as? [String: Any]).map { + CostUsageCodexTotals( + input: toInt($0["input_tokens"]), + cached: toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"]), + output: toInt($0["output_tokens"])) + } + let last = (info["last_token_usage"] as? [String: Any]).map { + CostUsageCodexTotals( + input: max(0, toInt($0["input_tokens"])), + cached: max(0, toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"])), + output: max(0, toInt($0["output_tokens"]))) } + appendSnapshot(timestamp: timestamp, last: last, total: total) } }) } catch is CancellationError { @@ -1253,6 +1545,222 @@ enum CostUsageScanner { } } + func handleSessionMetadata(_ metadata: CodexSessionMetadata) throws { + if sessionId == nil { + sessionId = metadata.sessionId + } + if forkedFromId == nil { + forkedFromId = metadata.forkedFromId + } + if let forkedFromId { + try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: metadata.forkTimestamp ?? "") + } + } + + // swiftlint:disable:next function_body_length + func handleTokenCount(_ record: CodexTokenCountRecord) throws { + guard let dayKey = Self.dayKeyFromTimestamp(record.timestamp) ?? Self.dayKeyFromParsedISO(record.timestamp) + else { return } + + let model = currentModel ?? record.model ?? "gpt-5" + let total = record.total + let last = record.last + + var deltaInput = 0 + var deltaCached = 0 + var deltaOutput = 0 + + func adjustedLastDelta(_ rawDelta: CostUsageCodexTotals) -> CostUsageCodexTotals { + guard var remaining = remainingInheritedTotals else { return rawDelta } + + let adjusted = CostUsageCodexTotals( + input: max(0, rawDelta.input - remaining.input), + cached: max(0, rawDelta.cached - remaining.cached), + output: max(0, rawDelta.output - remaining.output)) + + remaining.input = max(0, remaining.input - rawDelta.input) + remaining.cached = max(0, remaining.cached - rawDelta.cached) + remaining.output = max(0, remaining.output - rawDelta.output) + remainingInheritedTotals = if remaining.input == 0, remaining.cached == 0, + remaining.output == 0 + { + nil + } else { + remaining + } + + return adjusted + } + + let handledUnresolvedForkTotal = hasUnresolvedForkBaseline && total != nil + if hasUnresolvedForkBaseline, let total { + let currentRawTotals = total + defer { + unresolvedForkTotalWatermark = currentRawTotals + } + guard let last, + let watermark = unresolvedForkTotalWatermark + else { + return + } + + let rawLastDelta = last + let rawTotalDelta = Self.codexTotalDelta(from: watermark, to: currentRawTotals) + let adjustedDelta = Self.codexMinTotals(rawLastDelta, rawTotalDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, adjustedDelta) + rawTotalsBaseline = previousTotals + } + + if !handledUnresolvedForkTotal, + let total, + forkedFromId != nil, + !hasUnresolvedForkBaseline + { + let rawTotals = total + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: currentTotals) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true + } + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal, let last { + let rawDelta = last + let hadRemainingInheritedTotals = remainingInheritedTotals != nil + var adjustedDelta = adjustedLastDelta(rawDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + + if let total, !hasUnresolvedForkBaseline { + let rawTotals = total + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + if !hadRemainingInheritedTotals, + Self.codexShouldPreferTotalDelta( + rawBaseline: rawTotalsBaseline, + currentTotal: currentTotals, + totalDelta: totalDelta, + lastDelta: rawDelta, + sawDivergentTotals: sawDivergentTotals) + { + adjustedDelta = totalDelta + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + remainingInheritedTotals = nil + } + let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + previousTotals = countedTotals + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(currentTotals, countedTotals) { + sawDivergentTotals = true + } + } else { + let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + previousTotals = countedTotals + rawTotalsBaseline = countedTotals + } + } else if !handledUnresolvedForkTotal, let total { + let rawTotals = total + + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: currentTotals) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true + } + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal { + return + } + + if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } + let cachedClamp = min(deltaCached, deltaInput) + let normModel = CostUsagePricing.normalizeCodexModel(model) + add( + dayKey: dayKey, + model: normModel, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput) + if CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) + { + rows.append(CodexUsageRow( + day: dayKey, + model: normModel, + turnID: record.turnID ?? currentTurnID, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput)) + } + } + + func handleFastLine(_ fastLine: CodexFastLine) throws { + switch fastLine { + case let .sessionMeta(metadata): + try handleSessionMetadata(metadata) + case let .turnContext(model): + if let model { + currentModel = model + } + case let .taskStarted(turnID): + currentTurnID = turnID + case let .tokenCount(record): + try handleTokenCount(record) + } + } + let maxLineBytes = 256 * 1024 // Bumped from 32KB to maxLineBytes in 0.23.3: Codex CLI 0.125+ emits // turn_context lines ~38–41KB (bundled user_instructions / project @@ -1310,6 +1818,15 @@ enum CostUsageScanner { return } + if let fastLine = Self.parseCodexFastLine(line.bytes) { + do { + try handleFastLine(fastLine) + } catch { + deferredError = error + } + return + } + autoreleasepool { guard let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], @@ -1789,7 +2306,6 @@ enum CostUsageScanner { } let filePathsInScan = Set(files.map(\.path)) - var scanState = CodexScanState() let fileIndex = CodexSessionFileIndex( files: files, diff --git a/Tests/CodexBarTests/AmpUsageFetcherTests.swift b/Tests/CodexBarTests/AmpUsageFetcherTests.swift index 135f58ee..25963d8d 100644 --- a/Tests/CodexBarTests/AmpUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AmpUsageFetcherTests.swift @@ -17,11 +17,22 @@ struct AmpUsageFetcherTests { #expect(!AmpUsageFetcher.shouldAttachCookie(to: nil)) } + @Test + func `rejects non https amp urls`() { + #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://ampcode.com/settings"))) + #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://www.ampcode.com"))) + #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://app.ampcode.com/path"))) + } + @Test func `detects login redirects`() throws { let signIn = try #require(URL(string: "https://ampcode.com/auth/sign-in?returnTo=%2Fsettings")) #expect(AmpUsageFetcher.isLoginRedirect(signIn)) + let downgradedSignIn = try #require(URL(string: "http://ampcode.com/auth/sign-in?returnTo=%2Fsettings")) + #expect(AmpUsageFetcher.isLoginRedirect(downgradedSignIn)) + #expect(!AmpUsageFetcher.shouldAttachCookie(to: downgradedSignIn)) + let sso = try #require(URL(string: "https://ampcode.com/auth/sso?returnTo=%2Fsettings")) #expect(AmpUsageFetcher.isLoginRedirect(sso)) diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index f9f0a1cf..fe35ba60 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -805,7 +805,7 @@ struct AntigravityStatusProbeTests { extension AntigravityStatusProbeTests { @Test - func `extra rate windows preserve all model quotas in stable label order`() throws { + func `extra rate windows preserve all model quotas in stable family order`() throws { let resetTime = Date(timeIntervalSince1970: 1_775_000_000) let snapshot = AntigravityStatusSnapshot( modelQuotas: [ @@ -835,11 +835,13 @@ extension AntigravityStatusProbeTests { resetDescription: nil), ], accountEmail: nil, - accountPlan: nil) + accountPlan: nil, + source: .local) let usage = try snapshot.toUsageSnapshot() let extraWindows = try #require(usage.extraRateWindows) + // Local source shows all models. Order: Claude → Gemini Pro (High before Low) → GPT-OSS (unknown, last) #expect(extraWindows.map(\.id) == [ "MODEL_PLACEHOLDER_M50", "MODEL_PLACEHOLDER_M52", @@ -907,7 +909,8 @@ extension AntigravityStatusProbeTests { resetDescription: nil), ], accountEmail: "test@example.com", - accountPlan: "Pro") + accountPlan: "Pro", + source: .local) let usage = try snapshot.toUsageSnapshot() #expect(usage.primary?.remainingPercent.rounded() == 20) @@ -917,6 +920,481 @@ extension AntigravityStatusProbeTests { #expect(usage.loginMethod(for: .antigravity) == "Pro") } + // MARK: - Source-aware filter + sort tests + + @Test + func `local source shows all models including gpt oss at full remaining fraction`() throws { + // Fixture A: 8 opaque-ID models, source .local → all shown (show-all path) + let resetTime = Date(timeIntervalSince1970: 1_775_000_000) + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet 4.6 (Thinking)", + modelId: "MODEL_PLACEHOLDER_M60", + remainingFraction: 0.8, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Opus 4.6 (Thinking)", + modelId: "MODEL_PLACEHOLDER_M61", + remainingFraction: 0.7, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M62", + remainingFraction: 0.9, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M63", + remainingFraction: 0.4, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.5 Flash (High)", + modelId: "MODEL_PLACEHOLDER_M64", + remainingFraction: 0.6, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.5 Flash (Low)", + modelId: "MODEL_PLACEHOLDER_M65", + remainingFraction: 0.3, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.5 Flash (Medium)", + modelId: "MODEL_PLACEHOLDER_M66", + remainingFraction: 0.5, + resetTime: resetTime, + resetDescription: nil), + // GPT-OSS pinned at remainingFraction == 1.0 — shown by local show-all + AntigravityModelQuota( + label: "GPT-OSS 120B (Medium)", + modelId: "MODEL_PLACEHOLDER_M55", + remainingFraction: 1.0, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let ids = extraWindows.map(\.id) + + // All 8 models present + #expect(ids.count == 8) + // GPT-OSS shown despite remainingFraction == 1.0 (local show-all regression guard) + #expect(ids.contains("MODEL_PLACEHOLDER_M55")) + + // Order: Claude (version 4.6 → both at same version, Opus vs Sonnet by label) + // → Gemini Pro 3.1 (High before Low) + // → Gemini Flash 3.5 (High, Medium, Low by tier) + // → GPT-OSS (unknown bucket, last) + let titles = extraWindows.map(\.title) + // Claude family first + let claudeRange = titles.prefix(2) + #expect(claudeRange.allSatisfy { $0.lowercased().contains("claude") }) + // Gemini Pro next + let geminiProRange = titles.dropFirst(2).prefix(2) + #expect(geminiProRange.allSatisfy { $0.lowercased().contains("gemini") && $0.lowercased().contains("pro") }) + // Gemini Flash next + let geminiFlashRange = titles.dropFirst(4).prefix(3) + #expect(geminiFlashRange.allSatisfy { $0.lowercased().contains("gemini") && $0.lowercased().contains("flash") }) + // GPT-OSS last + #expect(titles.last == "GPT-OSS 120B (Medium)") + + // Within Gemini Pro 3.1: High before Low + let proTitles = Array(geminiProRange) + #expect(proTitles[0].contains("High")) + #expect(proTitles[1].contains("Low")) + + // Within Gemini Flash 3.5: High(0) → Medium(1) → Low(2) + let flashTitles = Array(geminiFlashRange) + #expect(flashTitles[0].contains("High")) + #expect(flashTitles[1].contains("Medium")) + #expect(flashTitles[2].contains("Low")) + } + + @Test + func `remote source filters junk models and keeps family recognized ones`() throws { + // Fixture B: verified 13 remote models; 6 junk hidden, 7 survivors present + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + // junk: image + AntigravityModelQuota( + label: "Gemini 2.5 Flash Image", + modelId: "gemini-2-5-flash-image", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: tab autocomplete + AntigravityModelQuota( + label: "Tab Flash Lite Vertex", + modelId: "tab_flash_lite_vertex", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 2.5 Pro", + modelId: "gemini-2-5-pro", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "gemini-3-pro-high", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: lite + AntigravityModelQuota( + label: "Gemini 2.5 Flash Lite", + modelId: "gemini-2-5-flash-lite", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: image + AntigravityModelQuota( + label: "Gemini 3 Pro Image", + modelId: "gemini-3-pro-image", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "gemini-3-flash", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: lite + AntigravityModelQuota( + label: "Gemini 3.1 Flash Lite", + modelId: "gemini-3-1-flash-lite", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3.1 Pro (Low)", + modelId: "gemini-3-1-pro-low", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3.1 Pro (High)", + modelId: "gemini-3-1-pro-high", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: tab autocomplete + AntigravityModelQuota( + label: "Tab Jump Flash Lite Vertex", + modelId: "tab_jump_flash_lite_vertex", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3 Pro (Low)", + modelId: "gemini-3-pro-low", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 2.5 Flash", + modelId: "gemini-2-5-flash", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let ids = extraWindows.map(\.id) + + // 6 junk IDs must be absent + #expect(!ids.contains("gemini-2-5-flash-image")) + #expect(!ids.contains("tab_flash_lite_vertex")) + #expect(!ids.contains("gemini-2-5-flash-lite")) + #expect(!ids.contains("gemini-3-pro-image")) + #expect(!ids.contains("gemini-3-1-flash-lite")) + #expect(!ids.contains("tab_jump_flash_lite_vertex")) + + // 7 survivors must be present by ID + #expect(ids.contains("gemini-2-5-pro")) + #expect(ids.contains("gemini-3-pro-high")) + #expect(ids.contains("gemini-3-flash")) + #expect(ids.contains("gemini-3-1-pro-low")) + #expect(ids.contains("gemini-3-1-pro-high")) + #expect(ids.contains("gemini-3-pro-low")) + #expect(ids.contains("gemini-2-5-flash")) + + // Version-descending within Gemini Pro: 3.1 before 3 before 2.5 + let proIds = ids.filter { $0.contains("pro") && !$0.contains("image") } + let proIndexOf: (String) -> Int = { id in proIds.firstIndex(of: id) ?? Int.max } + #expect(proIndexOf("gemini-3-1-pro-high") < proIndexOf("gemini-3-pro-high")) + #expect(proIndexOf("gemini-3-pro-high") < proIndexOf("gemini-2-5-pro")) + + // Within same version, High before Low + #expect(proIndexOf("gemini-3-1-pro-high") < proIndexOf("gemini-3-1-pro-low")) + #expect(proIndexOf("gemini-3-pro-high") < proIndexOf("gemini-3-pro-low")) + } + + @Test + func `remote source shows consumed junk models despite filter`() throws { + // Fixture C: junk models with remainingFraction < 0.999 must be shown + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + // consumed tab — should be shown + AntigravityModelQuota( + label: "Tab Flash Lite Vertex", + modelId: "tab_flash_lite_vertex", + remainingFraction: 0.4, + resetTime: nil, + resetDescription: nil), + // consumed image — should be shown + AntigravityModelQuota( + label: "Gemini 3 Pro Image", + modelId: "gemini-3-pro-image", + remainingFraction: 0.4, + resetTime: nil, + resetDescription: nil), + // unconsumed sibling tab (0.9995 >= 0.999) — should be hidden + AntigravityModelQuota( + label: "Tab Jump Flash Lite Vertex", + modelId: "tab_jump_flash_lite_vertex", + remainingFraction: 0.9995, + resetTime: nil, + resetDescription: nil), + // a clean survivor for non-empty guard + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "gemini-3-flash", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let ids = extraWindows.map(\.id) + + // Consumed junk models shown despite being junk type + #expect(ids.contains("tab_flash_lite_vertex")) + #expect(ids.contains("gemini-3-pro-image")) + + // Unconsumed sibling stays hidden + #expect(!ids.contains("tab_jump_flash_lite_vertex")) + } + + @Test + func `remote source image models do not drive family summary bars`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3 Pro Image", + modelId: "gemini-3-pro-image", + remainingFraction: 0.2, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "gemini-3-pro-high", + remainingFraction: 0.9, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Flash Image", + modelId: "gemini-3-flash-image", + remainingFraction: 0.1, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "gemini-3-flash", + remainingFraction: 0.8, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + + #expect(usage.secondary?.usedPercent == 10) + #expect(usage.tertiary?.usedPercent == 20) + #expect(usage.extraRateWindows?.map(\.id).contains("gemini-3-pro-image") == true) + #expect(usage.extraRateWindows?.map(\.id).contains("gemini-3-flash-image") == true) + } + + @Test + func `remote source yields nil extra windows when all models are unconsumed junk`() throws { + // Fixture D: all-junk-unconsumed → extraRateWindows nil + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Tab Flash Lite Vertex", + modelId: "tab_flash_lite_vertex", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 2.5 Flash Lite", + modelId: "gemini-2-5-flash-lite", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro Image", + modelId: "gemini-3-pro-image", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Unknown Model X", + modelId: "unknown-model-x", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + #expect(usage.extraRateWindows == nil) + } + + @Test + func `ordering edge cases with unparseable version and equal version differing tier`() throws { + // Fixture F: local source; unparseable-version Gemini Pro lands last in pro group; + // same-version High precedes Low + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M70", + remainingFraction: 0.5, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M71", + remainingFraction: 0.8, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini Pro Experimental", + modelId: "MODEL_PLACEHOLDER_M72", + remainingFraction: 0.3, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Sonnet 4", + modelId: "MODEL_PLACEHOLDER_M73", + remainingFraction: 0.9, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let titles = extraWindows.map(\.title) + + // Claude first + #expect(titles[0] == "Claude Sonnet 4") + // Within Gemini Pro: version-3 models before unparseable-version model + // High before Low at same version + let proIndex: (String) -> Int = { t in titles.firstIndex(of: t) ?? Int.max } + #expect(proIndex("Gemini 3 Pro (High)") < proIndex("Gemini 3 Pro (Low)")) + #expect(proIndex("Gemini 3 Pro (High)") < proIndex("Gemini Pro Experimental")) + #expect(proIndex("Gemini 3 Pro (Low)") < proIndex("Gemini Pro Experimental")) + } + + @Test + func `nil version unknown family models sort deterministically by label`() throws { + // Strict-weak-ordering guard: two .unknown models with unparseable versions + // should sort by label without trapping + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Zebra Unknown Model", + modelId: "MODEL_PLACEHOLDER_MA", + remainingFraction: 0.5, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Alpha Unknown Model", + modelId: "MODEL_PLACEHOLDER_MB", + remainingFraction: 0.5, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let titles = extraWindows.map(\.title) + + // Deterministic: label tiebreaker → Alpha before Zebra + #expect(titles == ["Alpha Unknown Model", "Zebra Unknown Model"]) + } + + @Test + func `hyphenated raw model ids without display name parse minor version`() throws { + // When the remote catalog omits displayName/label, the raw hyphenated model id + // becomes the label. The newer 3.1 entry must still sort before the 3.0 entry. + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "gemini-3-pro-high", + modelId: "gemini-3-pro-high", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "gemini-3-1-pro-low", + modelId: "gemini-3-1-pro-low", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + let titles = try #require(usage.extraRateWindows).map(\.title) + + // 3.1 parses from the hyphenated id and sorts newest-first, ahead of 3.0. + #expect(titles == ["gemini-3-1-pro-low", "gemini-3-pro-high"]) + } + @Test func `http probe errors still count as reachable`() { #expect( diff --git a/Tests/CodexBarTests/AuggieCLIProbeParseTests.swift b/Tests/CodexBarTests/AuggieCLIProbeParseTests.swift new file mode 100644 index 00000000..338d8d2f --- /dev/null +++ b/Tests/CodexBarTests/AuggieCLIProbeParseTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) + +struct AuggieCLIProbeParseTests { + private let probe = AuggieCLIProbe() + + @Test + func `parses current auggie account status output`() throws { + let output = """ + ╭ Account ───────────────────────────────────────────────╮ + │ │ + │ 319,054 credits remaining Max Plan │ + │ 450,000 credits / month │ + │ │ + ╰────────────────────────────────────────────────────────╯ + + 9 days remaining in this billing cycle (ends 6/9/2026) + For more detail, visit https://app.augmentcode.com/account + """ + + let snapshot = try probe.parse(output) + + #expect(snapshot.creditsRemaining == 319_054) + #expect(snapshot.creditsLimit == 450_000) + #expect(snapshot.creditsUsed == 130_946) + #expect(snapshot.accountPlan == "\(450_000.formatted()) credits/month") + #expect(snapshot.billingCycleEnd != nil) + } + + @Test + func `parses legacy auggie account status output`() throws { + let output = """ + Max Plan 450,000 credits / month + 11,657 remaining · 953,170 / 964,827 credits used + 2 days remaining in this billing cycle (ends 1/8/2026) + """ + + let snapshot = try probe.parse(output) + + #expect(snapshot.creditsRemaining == 11657) + #expect(snapshot.creditsUsed == 953_170) + #expect(snapshot.creditsLimit == 964_827) + #expect(snapshot.accountPlan == "\(450_000.formatted()) credits/month") + } +} + +#endif diff --git a/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift b/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift index 58d9b0b6..905a8e92 100644 --- a/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift +++ b/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift @@ -79,10 +79,10 @@ struct AugmentCLIFetchStrategyFallbackTests { } @Test - func `parse error does not fall back`() { + func `parse error falls back to web`() { let strategy = AugmentCLIFetchStrategy() let context = self.makeContext() - #expect(strategy.shouldFallback(on: AuggieCLIError.parseError("bad data"), context: context) == false) + #expect(strategy.shouldFallback(on: AuggieCLIError.parseError("bad data"), context: context) == true) } @Test diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index 4ce346e2..f6a16d50 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -5,6 +5,7 @@ import Testing #if os(macOS) import SweetCookieKit +@Suite(.serialized) struct BrowserDetectionTests { @Test func `safari always installed`() { @@ -103,27 +104,73 @@ struct BrowserDetectionTests { let start = Date(timeIntervalSince1970: 1000) var preflightCount = 0 + KeychainAccessGate.withTaskOverrideForTesting(false) { + ProviderInteractionContext.$current.withValue(.userInitiated) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + preflightCount += 1 + return .interactionRequired + } operation: { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == false) + } + + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + preflightCount += 1 + return .allowed + } operation: { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) + #expect( + BrowserCookieAccessGate.shouldAttempt( + .chrome, + now: start.addingTimeInterval((60 * 60 * 6) + 1)) == true) + } + } + } + + #expect(preflightCount == 2) + } + + @Test + func `background cookie import allows authorized chromium keychain sources`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + var preflightCount = 0 + KeychainAccessGate.withTaskOverrideForTesting(false) { KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in preflightCount += 1 - return .interactionRequired + return .allowed } operation: { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == false) + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == true) + #expect(BrowserCookieAccessGate.shouldAttempt(.safari) == true) + } } + } + + #expect(preflightCount == 1) + } + + @Test + func `background cookie import suppresses chromium keychain sources requiring interaction`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + var preflightCount = 0 + KeychainAccessGate.withTaskOverrideForTesting(false) { KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in preflightCount += 1 - return .allowed + return .interactionRequired } operation: { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) - #expect( - BrowserCookieAccessGate.shouldAttempt( - .chrome, - now: start.addingTimeInterval((60 * 60 * 6) + 1)) == true) + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.safari) == true) + } } } - #expect(preflightCount == 2) + #expect(preflightCount == 1) } @Test diff --git a/Tests/CodexBarTests/CLIServeRouterTests.swift b/Tests/CodexBarTests/CLIServeRouterTests.swift index 84149ed5..4b21f61b 100644 --- a/Tests/CodexBarTests/CLIServeRouterTests.swift +++ b/Tests/CodexBarTests/CLIServeRouterTests.swift @@ -105,6 +105,27 @@ struct CLIServeRouterTests { positional: [], options: [:], flags: [])) == 60) + + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: ["requestTimeout": ["soon"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: ["requestTimeout": ["-0.5"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: ["requestTimeout": ["0"]], + flags: [])) == 0) + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: ["requestTimeout": ["12.5"]], + flags: [])) == 12.5) + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: [:], + flags: [])) == 30) } @Test @@ -124,6 +145,139 @@ struct CLIServeRouterTests { #expect(!CodexBarCLI.shouldCacheServeResponse(routeError)) } + @Test + func `serve cache coalesces concurrent cache misses`() async { + let cache = CLIServeResponseCache() + let counter = ServeTestCounter() + + let responses = await withTaskGroup(of: CLILocalHTTPResponse.self) { group -> [CLILocalHTTPResponse] in + for _ in 0..<5 { + group.addTask { + await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 1) + { + let call = await counter.increment() + try? await Task.sleep(nanoseconds: 50_000_000) + return Self.response("[{\"provider\":\"codex\",\"call\":\(call)}]") + } + } + } + + var responses: [CLILocalHTTPResponse] = [] + for await response in group { + responses.append(response) + } + return responses + } + + #expect(await counter.current() == 1) + #expect(Set(responses.map(Self.bodyString)).count == 1) + #expect(responses.allSatisfy { $0.status == .ok }) + #expect(responses.allSatisfy { Self.bodyString($0).contains("\"call\":1") }) + } + + @Test + func `serve cache does not cache timeouts and recovers on next success`() async { + let cache = CLIServeResponseCache() + let counter = ServeTestCounter() + + let timeout = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 0.01) + { + _ = await counter.increment() + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response("[{\"provider\":\"codex\",\"call\":1}]") + } + + #expect(timeout.status == .gatewayTimeout) + #expect(Self.bodyString(timeout).contains("request timed out")) + + let success = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 1) + { + let call = await counter.increment() + return Self.response("[{\"provider\":\"codex\",\"call\":\(call)}]") + } + + #expect(success.status == .ok) + #expect(Self.bodyString(success).contains("\"call\":2")) + + let cached = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 1) + { + let call = await counter.increment() + return Self.response("[{\"provider\":\"codex\",\"call\":\(call)}]") + } + + #expect(cached.status == .ok) + #expect(Self.bodyString(cached) == Self.bodyString(success)) + #expect(await counter.current() == 2) + } + + @Test + func `serve cache resumes coalesced waiters on timeout`() async { + let cache = CLIServeResponseCache() + let counter = ServeTestCounter() + + let responses = await withTaskGroup(of: CLILocalHTTPResponse.self) { group -> [CLILocalHTTPResponse] in + for _ in 0..<4 { + group.addTask { + await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 0.01) + { + _ = await counter.increment() + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response("[{\"provider\":\"codex\"}]") + } + } + } + + var responses: [CLILocalHTTPResponse] = [] + for await response in group { + responses.append(response) + } + return responses + } + + #expect(await counter.current() == 1) + #expect(responses.count == 4) + #expect(responses.allSatisfy { $0.status == .gatewayTimeout }) + #expect(responses.allSatisfy { Self.bodyString($0).contains("request timed out") }) + } + + @Test + func `serve request timeout zero disables the deadline`() async { + let cache = CLIServeResponseCache() + + let response = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0, + requestTimeout: 0) + { + try? await Task.sleep(nanoseconds: 80_000_000) + return Self.response("[{\"provider\":\"codex\",\"slow\":true}]") + } + + #expect(response.status == .ok) + #expect(Self.bodyString(response).contains("\"slow\":true")) + } + private static func parsedRequest(host: String) throws -> CLILocalHTTPRequest { let raw = "GET /usage?provider=claude HTTP/1.1\r\nHost: \(host)\r\n\r\n" return try CLILocalHTTPRequest.parse(Data(raw.utf8)).get() @@ -137,4 +291,25 @@ struct CLIServeRouterTests { #expect(error == expected) } } + + private static func response(_ body: String, status: CLIHTTPStatus = .ok) -> CLILocalHTTPResponse { + CLILocalHTTPResponse(status: status, body: Data(body.utf8)) + } + + private static func bodyString(_ response: CLILocalHTTPResponse) -> String { + String(data: response.body, encoding: .utf8) ?? "" + } +} + +private actor ServeTestCounter { + private var value = 0 + + func increment() -> Int { + self.value += 1 + return self.value + } + + func current() -> Int { + self.value + } } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift new file mode 100644 index 00000000..65dcba04 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift @@ -0,0 +1,254 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { + private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { + let millis = Int(expiresAt.timeIntervalSince1970 * 1000) + let refreshField: String = { + guard let refreshToken else { return "" } + return ",\n \"refreshToken\": \"\(refreshToken)\"" + }() + let json = """ + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(millis), + "scopes": ["user:profile"]\(refreshField) + } + } + """ + return Data(json.utf8) + } + + @Test + func `load record treats codexbar cache as claude CLI owned when credentials file exists`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let fileData = self.makeCredentialsData( + accessToken: "claude-cli-file", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "cli-refresh-token") + try fileData.write(to: fileURL) + + let cachedData = self.makeCredentialsData( + accessToken: "codexbar-cache", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "cached-refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: cachedData, + storedAt: Date(timeIntervalSinceNow: 60), + owner: .codexbar)) + + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + + #expect(record.credentials.accessToken == "codexbar-cache") + #expect(record.owner == .claudeCLI) + #expect(record.source == .cacheKeychain) + } + } + } + } + } + } + + @Test + func `load with auto refresh delegates expired codexbar cache when credentials file exists`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + try Data("not valid credentials".utf8).write(to: fileURL) + + let expiredData = self.makeCredentialsData( + accessToken: "expired-codexbar-with-file", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "cached-refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(timeIntervalSinceNow: 60), + owner: .codexbar)) + + await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) { + do { + _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + Issue.record("Expected delegated refresh error when Claude CLI file is present") + } catch let error as ClaudeOAuthCredentialsError { + guard case .refreshDelegatedToClaudeCLI = error else { + Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + } + } + } + } + } + } + } + + @Test + func `load with auto refresh keeps codexbar cache ownership without Claude CLI storage`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let fileURL = tempDir.appendingPathComponent("missing-credentials.json") + await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let expiredData = self.makeCredentialsData( + accessToken: "expired-codexbar-only", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "cached-refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(timeIntervalSinceNow: 60), + owner: .codexbar)) + + await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) { + do { + _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + Issue.record("Expected direct CodexBar refresh failure") + } catch let error as ClaudeOAuthCredentialsError { + guard case let .refreshFailed(message) = error else { + Issue.record("Expected .refreshFailed, got \(error)") + return + } + #expect(message.contains("suppressed") || message.contains("backed off")) + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + } + } + } + } + } + } + } + + @Test + func `load record treats codexbar cache as claude CLI owned when Claude keychain item exists`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let cachedData = self.makeCredentialsData( + accessToken: "codexbar-cache", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "cached-refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: cachedData, + storedAt: Date(), + owner: .codexbar)) + + let keychainData = self.makeCredentialsData( + accessToken: "claude-keychain", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "keychain-refresh-token") + + let record = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + } + } + + #expect(record.credentials.accessToken == "codexbar-cache") + #expect(record.owner == .claudeCLI) + #expect(record.source == .cacheKeychain) + } + } + } + } + } +} diff --git a/Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift b/Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift new file mode 100644 index 00000000..cc2cec5e --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift @@ -0,0 +1,175 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ClaudeWebRefreshResilienceTests { + @Test + func `web unauthorized respects failure gate while keeping prior Claude snapshot`() async throws { + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("missing-credentials.json") + + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let prior = Self.makePriorSnapshot() + let store = try await MainActor.run { + try Self.makeStore( + suite: "ClaudeWebRefreshResilienceTests-web-unauthorized", + prior: prior) + } + + await store.refreshProvider(.claude) + let firstResult = await MainActor.run { + ( + updatedAt: store.snapshot(for: .claude)?.updatedAt, + hasError: store.error(for: .claude) != nil) + } + + #expect(firstResult.updatedAt == prior.updatedAt) + #expect(!firstResult.hasError) + + await store.refreshProvider(.claude) + let secondResult = await MainActor.run { + ( + updatedAt: store.snapshot(for: .claude)?.updatedAt, + error: store.error(for: .claude)) + } + + #expect(secondResult.updatedAt == prior.updatedAt) + #expect(secondResult.error == ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription) + } + } + } + + @Test + func `web unauthorized without prior Claude snapshot still surfaces failure`() async throws { + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("missing-credentials.json") + + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let store = try await MainActor.run { + try Self.makeStore( + suite: "ClaudeWebRefreshResilienceTests-web-unauthorized-no-prior", + prior: nil) + } + + await store.refreshProvider(.claude) + let result = await MainActor.run { + ( + hasSnapshot: store.snapshot(for: .claude) != nil, + error: store.error(for: .claude)) + } + + #expect(!result.hasSnapshot) + #expect(result.error == ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription) + } + } + } + + @MainActor + private static func makeStore(suite: String, prior: UsageSnapshot?) throws -> UsageStore { + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .web + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: provider == .claude) + } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + if let prior { + store._setSnapshotForTesting(prior, provider: .claude) + } + + let baseSpec = try #require(store.providerSpecs[.claude]) + let descriptor = ProviderDescriptor( + id: .claude, + metadata: baseSpec.descriptor.metadata, + branding: baseSpec.descriptor.branding, + tokenCost: baseSpec.descriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.web], + pipeline: ProviderFetchPipeline { _ in [ClaudeWebUnauthorizedFetchStrategy()] }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + private static func makePriorSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 34, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + } + + @MainActor + private static func makeSettingsStore(suite: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + return settings + } +} + +private struct ClaudeWebUnauthorizedFetchStrategy: ProviderFetchStrategy { + let id = "test.claude-web-unauthorized" + let kind: ProviderFetchKind = .web + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw ClaudeWebAPIFetcher.FetchError.unauthorized + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Tests/CodexBarTests/CodexAccountReconciliationTests.swift b/Tests/CodexBarTests/CodexAccountReconciliationTests.swift index 438ae652..cf53f30e 100644 --- a/Tests/CodexBarTests/CodexAccountReconciliationTests.swift +++ b/Tests/CodexBarTests/CodexAccountReconciliationTests.swift @@ -124,6 +124,93 @@ struct CodexAccountReconciliationTests { #expect(projection.liveVisibleAccountID == "ambient@example.com") } + @Test + @MainActor + func `settings store can reuse short lived codex reconciliation snapshot`() throws { + let suite = "CodexAccountReconciliationTests-short-lived-cache" + let settings = try Self.makeSettings(suite: suite) + let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "cached@example.com", plan: "pro") + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": ambientHome.path] + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: ambientHome) + } + + let first = settings.codexAccountReconciliationSnapshot + try FileManager.default.removeItem(at: ambientHome) + let cached = settings.codexAccountReconciliationSnapshot + settings.invalidateCodexAccountReconciliationSnapshotCache() + let refreshed = settings.codexAccountReconciliationSnapshot + + #expect(first.liveSystemAccount?.email == "cached@example.com") + #expect(cached.liveSystemAccount?.email == "cached@example.com") + #expect(refreshed.liveSystemAccount == nil) + } + + @Test + @MainActor + func `codex active source write invalidates short lived reconciliation snapshot`() throws { + let suite = "CodexAccountReconciliationTests-active-source-cache-invalidation" + let settings = try Self.makeSettings(suite: suite) + let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "before@example.com", plan: "pro") + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": ambientHome.path] + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: ambientHome) + } + + #expect(settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email == "before@example.com") + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "after@example.com", plan: "pro") + settings.codexActiveSource = .liveSystem + + #expect(settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email == "after@example.com") + } + + @Test + @MainActor + func `managed account changes invalidate short lived reconciliation snapshot`() throws { + let suite = "CodexAccountReconciliationTests-managed-change-cache-invalidation" + let settings = try Self.makeSettings(suite: suite) + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-store-\(UUID().uuidString).json") + try Self.writeManagedCodexStore( + ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: []), + to: storeURL) + let stored = ManagedCodexAccount( + id: UUID(), + email: "stored@example.com", + managedHomePath: "/tmp/stored-managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: stored.id) + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + } + + #expect(settings.codexAccountReconciliationSnapshot.storedAccounts.isEmpty) + try Self.writeManagedCodexStore( + ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: [stored]), + to: storeURL) + settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange() + + #expect(settings.codexAccountReconciliationSnapshot.storedAccounts.map(\.id) == [stored.id]) + } + @Test @MainActor func `settings store home path override also keeps reconciliation hermetic`() throws { diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift index 3e8a519f..4316b0ad 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift @@ -33,7 +33,7 @@ extension CodexAccountScopedRefreshTests { defer { store._test_codexCreditsLoaderOverride = nil } var observedTargetEmail: String? - store._test_openAIDashboardLoaderOverride = { accountEmail, _, _ in + store._test_openAIDashboardLoaderOverride = { accountEmail, _, _, _ in observedTargetEmail = accountEmail #expect(store.currentCodexOpenAIWebRefreshGuard().source == .liveSystem) #expect(store.currentCodexOpenAIWebRefreshGuard().identity == .unresolved) diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift index e2ee1d4a..8e0fc39c 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift @@ -429,6 +429,17 @@ actor BlockingWidgetSnapshotSaver { } } + func waitUntilStartedWithin(count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let startedAt = ContinuousClock.now + while self.snapshots.count < count { + if startedAt.duration(to: .now) >= timeout { + return false + } + try? await Task.sleep(for: .milliseconds(50)) + } + return true + } + func startedCount() -> Int { self.snapshots.count } @@ -443,3 +454,26 @@ actor BlockingWidgetSnapshotSaver { self.snapshots } } + +actor RecordingWidgetSnapshotSaver { + private var snapshots: [WidgetSnapshot] = [] + + func save(_ snapshot: WidgetSnapshot) { + self.snapshots.append(snapshot) + } + + func waitUntilSavedWithin(count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let startedAt = ContinuousClock.now + while self.snapshots.count < count { + if startedAt.duration(to: .now) >= timeout { + return false + } + try? await Task.sleep(for: .milliseconds(50)) + } + return true + } + + func savedSnapshots() -> [WidgetSnapshot] { + self.snapshots + } +} diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index 6eb33679..91f0b5da 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -338,7 +338,7 @@ struct CodexAccountScopedRefreshTests { let store = self.makeUsageStore(settings: settings) store.lastKnownLiveSystemCodexEmail = nil - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in self.dashboard(email: "seeded@example.com", creditsRemaining: 33, usedPercent: 12) } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -379,7 +379,7 @@ struct CodexAccountScopedRefreshTests { self.codexSnapshot(email: "trusted@example.com", usedPercent: 12), provider: .codex) store.lastSourceLabels[.codex] = "codex-cli" - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in self.dashboard(email: "trusted@example.com", creditsRemaining: 33, usedPercent: 12) } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -606,14 +606,17 @@ struct CodexAccountScopedRefreshTests { settings.refreshFrequency = .manual settings.openAIWebAccessEnabled = true settings.codexCookieSource = .auto + settings.statusChecksEnabled = false settings._test_liveSystemCodexAccount = self.liveAccount(email: "alpha@example.com") let store = self.makeUsageStore(settings: settings) self.installImmediateCodexProvider( on: store, snapshot: self.codexSnapshot(email: "alpha@example.com", usedPercent: 18)) + await store.refresh() + let dashboardBlocker = BlockingOpenAIDashboardLoader() - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await dashboardBlocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } diff --git a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift index bac3cd40..6b0aa0ab 100644 --- a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift +++ b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift @@ -117,11 +117,14 @@ struct CodexBackgroundRefreshCoalescingTests { try await blocker.awaitResult() } defer { store._test_codexCreditsLoaderOverride = nil } + let regularCompletion = RefreshCompletionProbe() let regularRefreshTask = Task { await store.refresh(forceTokenUsage: false) + await regularCompletion.markCompleted() } await blocker.waitUntilStarted(count: 1) + #expect(await regularCompletion.waitUntilCompleted() == true) let forceRefreshTask = Task { await store.refresh(forceTokenUsage: true) @@ -158,7 +161,9 @@ struct CodexBackgroundRefreshCoalescingTests { CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) } defer { store._test_codexCreditsLoaderOverride = nil } - store._test_openAIDashboardLoaderOverride = { _, _, _ in + await store.refresh(forceTokenUsage: false) + + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index a61a5c87..a5ed210f 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -30,6 +30,7 @@ struct CodexManagedOpenAIWebRefreshTests { lastAuthenticatedAt: 1) settings._test_activeManagedCodexAccount = managedAccount settings.codexActiveSource = .managedAccount(id: managedAccount.id) + settings.openAIWebAccessEnabled = false defer { settings._test_activeManagedCodexAccount = nil } let store = UsageStore( @@ -45,7 +46,11 @@ struct CodexManagedOpenAIWebRefreshTests { CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) } defer { store._test_codexCreditsLoaderOverride = nil } - store._test_openAIDashboardLoaderOverride = { _, _, _ in + + await store.refresh(forceTokenUsage: false) + settings.openAIWebAccessEnabled = true + + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -212,6 +217,7 @@ struct CodexManagedOpenAIWebRefreshTests { await saver.resumeNext() let backgroundTask = try #require(store.creditsRefreshTask) + await creditsBlocker.waitUntilStarted(count: 1) await creditsBlocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) await backgroundTask.value await saver.waitUntilStarted(count: 2) @@ -249,6 +255,7 @@ struct CodexManagedOpenAIWebRefreshTests { lastAuthenticatedAt: 1) settings._test_activeManagedCodexAccount = managedAccount settings.codexActiveSource = .managedAccount(id: managedAccount.id) + settings.openAIWebAccessEnabled = false defer { settings._test_activeManagedCodexAccount = nil } let store = UsageStore( @@ -256,20 +263,25 @@ struct CodexManagedOpenAIWebRefreshTests { browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, startupBehavior: .testing) - store.snapshots[.codex] = Self.codexSnapshot(email: managedAccount.email, usedPercent: 18) - store.creditsRefreshTask = Task {} - store.creditsRefreshTaskKey = store.codexCreditsRefreshKey( - expectedGuard: store.currentCodexAccountScopedRefreshGuard()) let dashboardBlocker = BlockingManagedOpenAIDashboardLoader() - let saver = BlockingWidgetSnapshotSaver() + let saver = RecordingWidgetSnapshotSaver() store._test_providerRefreshOverride = { _ in } defer { store._test_providerRefreshOverride = nil } store._test_codexCreditsLoaderOverride = { CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) } defer { store._test_codexCreditsLoaderOverride = nil } - store._test_openAIDashboardLoaderOverride = { _, _, _ in + + await store.refresh(forceTokenUsage: false) + await store.widgetSnapshotPersistTask?.value + settings.openAIWebAccessEnabled = true + store.snapshots[.codex] = Self.codexSnapshot(email: managedAccount.email, usedPercent: 18) + store.creditsRefreshTask = Task {} + store.creditsRefreshTaskKey = store.codexCreditsRefreshKey( + expectedGuard: store.currentCodexAccountScopedRefreshGuard()) + + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await dashboardBlocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -283,34 +295,33 @@ struct CodexManagedOpenAIWebRefreshTests { } await refreshTask.value - await saver.waitUntilStarted(count: 1) + let didPersistInitialRefreshSnapshot = await saver.waitUntilSavedWithin(count: 1) + #expect(didPersistInitialRefreshSnapshot) let firstSnapshots = await saver.savedSnapshots() - let firstCodexEntry = try #require(firstSnapshots.first?.entries.first { $0.provider == .codex }) - #expect(firstCodexEntry.codeReviewRemainingPercent == nil) + #expect(firstSnapshots.first?.entries.first { $0.provider == .codex }?.codeReviewRemainingPercent == nil) - await saver.resumeNext() let backgroundTask = try #require(store.openAIDashboardBackgroundRefreshTask) - await dashboardBlocker.resumeNext(with: .success(OpenAIDashboardSnapshot( - signedInEmail: managedAccount.email, - codeReviewRemainingPercent: 95, - creditEvents: [], - dailyBreakdown: [], - usageBreakdown: [], - creditsPurchaseURL: nil, - creditsRemaining: 25, - accountPlan: "Pro", - updatedAt: Date()))) - await backgroundTask.value - await saver.waitUntilStarted(count: 2) - - #expect(await saver.startedCount() == 2) + let didStartDashboardRefresh = await dashboardBlocker.waitUntilStartedWithin(count: 1) + #expect(didStartDashboardRefresh) + if didStartDashboardRefresh { + await dashboardBlocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + await backgroundTask.value + } + let didPersistDashboardSnapshot = await saver.waitUntilSavedWithin(count: 2) + + #expect(didPersistDashboardSnapshot) let secondSnapshots = await saver.savedSnapshots() - let secondCodexEntry = try #require(secondSnapshots.last?.entries.first { $0.provider == .codex }) - #expect(secondCodexEntry.codeReviewRemainingPercent == 95) - - await saver.resumeNext() - await store.widgetSnapshotPersistTask?.value + #expect(secondSnapshots.count >= 2) } @Test @@ -341,7 +352,7 @@ struct CodexManagedOpenAIWebRefreshTests { settings: settings, startupBehavior: .testing) let blocker = BlockingManagedOpenAIDashboardLoader() - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -415,7 +426,7 @@ struct CodexManagedOpenAIWebRefreshTests { startupBehavior: .testing) store.openAIDashboardCookieImportStatus = "OpenAI cookies are for other@example.com, not managed@example.com." - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in throw ManagedDashboardTestError.networkTimeout } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -447,8 +458,10 @@ struct CodexManagedOpenAIWebRefreshTests { startupBehavior: .testing) let blocker = BlockingManagedOpenAIDashboardLoader() let importTracker = OpenAIDashboardImportCallTracker() - store._test_openAIDashboardLoaderOverride = { _, _, _ in - try await blocker.awaitResult() + var allowNavigationTimeoutRetries: [Bool] = [] + store._test_openAIDashboardLoaderOverride = { _, _, allowNavigationTimeoutRetry, _ in + allowNavigationTimeoutRetries.append(allowNavigationTimeoutRetry) + return try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in @@ -484,10 +497,65 @@ struct CodexManagedOpenAIWebRefreshTests { await refreshTask.value #expect(await blocker.startedCount() == 2) + #expect(allowNavigationTimeoutRetries == [true, true]) #expect(store.openAIDashboard?.creditsRemaining == 25) #expect(store.lastOpenAIDashboardError == nil) } + @Test + func `background navigation timeout skips immediate WebKit retry`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexManagedOpenAIWebRefreshTests-background-timeout-no-retry") + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let blocker = BlockingManagedOpenAIDashboardLoader() + let importTracker = OpenAIDashboardImportCallTracker() + var allowNavigationTimeoutRetries: [Bool] = [] + store._test_openAIDashboardLoaderOverride = { _, _, allowNavigationTimeoutRetry, _ in + allowNavigationTimeoutRetries.append(allowNavigationTimeoutRetry) + return try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + _ = await importTracker.recordCall() + return OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Chrome", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + defer { store._test_openAIDashboardCookieImportOverride = nil } + + let expectedGuard = store.currentCodexOpenAIWebRefreshGuard() + let refreshTask = Task { + await store.refreshOpenAIDashboardIfNeeded(force: false, expectedGuard: expectedGuard) + } + await blocker.waitUntilStarted(count: 1) + + await blocker.resumeNext(with: .failure(URLError(.timedOut))) + await refreshTask.value + + #expect(await blocker.startedCount() == 1) + #expect(allowNavigationTimeoutRetries == [false]) + #expect(await importTracker.callCount() == 0) + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardError?.contains("timed out") == true) + } + @Test func `reset open A I web state blocks stale in flight dashboard completion`() async throws { let settings = try self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-reset-invalidates-task") @@ -508,7 +576,7 @@ struct CodexManagedOpenAIWebRefreshTests { settings: settings, startupBehavior: .testing) let blocker = BlockingManagedOpenAIDashboardLoader() - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -560,7 +628,7 @@ struct CodexManagedOpenAIWebRefreshTests { startupBehavior: .testing) store.openAIDashboardCookieImportStatus = "OpenAI cookies are for other@example.com, not managed@example.com." - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in throw ManagedDashboardTestError.networkTimeout } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -794,6 +862,10 @@ private actor OpenAIDashboardImportCallTracker { } } + func callCount() -> Int { + self.calls + } + private func resumeReadyWaiters() { var remaining: [(count: Int, continuation: CheckedContinuation)] = [] for waiter in self.waiters { diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift index fce3d009..921cce7a 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift @@ -33,7 +33,7 @@ extension CodexManagedOpenAIWebTests { settings: settings, startupBehavior: .testing) let blocker = CoalescingManagedOpenAIDashboardLoader() - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift index da934f23..51cd309c 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift @@ -304,7 +304,7 @@ struct CodexManagedOpenAIWebTests { } var observedTargetEmail: String? - store._test_openAIDashboardLoaderOverride = { accountEmail, _, _ in + store._test_openAIDashboardLoaderOverride = { accountEmail, _, _, _ in observedTargetEmail = accountEmail return OpenAIDashboardSnapshot( signedInEmail: "new@example.com", @@ -365,7 +365,7 @@ struct CodexManagedOpenAIWebTests { store.lastSourceLabels[.codex] = "codex-cli" var observedTargetEmail: String? - store._test_openAIDashboardLoaderOverride = { accountEmail, _, _ in + store._test_openAIDashboardLoaderOverride = { accountEmail, _, _, _ in observedTargetEmail = accountEmail return OpenAIDashboardSnapshot( signedInEmail: "usage@example.com", @@ -818,7 +818,7 @@ struct CodexManagedOpenAIWebTests { startupBehavior: .testing) var loaderCalls = 0 - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in loaderCalls += 1 throw OpenAIDashboardFetcher.FetchError.loginRequired } @@ -860,7 +860,7 @@ struct CodexManagedOpenAIWebTests { settings: settings, startupBehavior: .testing) - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in throw OpenAIDashboardFetcher.FetchError.loginRequired } defer { store._test_openAIDashboardLoaderOverride = nil } diff --git a/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift b/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift index 631b8cbf..90d15059 100644 --- a/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift +++ b/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift @@ -31,6 +31,39 @@ struct CodexWebDashboardStrategyAuthorityTests { #expect(result.credits?.remaining == 42) } + @Test + func `web dashboard attach preserves credits when usage limits are absent`() throws { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let authHome = try self.makeAuthHome( + email: "owner@example.com", + accountId: "acct-owner") + defer { try? FileManager.default.removeItem(at: authHome) } + + let context = self.makeContext( + authHome: authHome, + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-owner"), + normalizedEmail: "owner@example.com"), + ]) + let dashboard = self.makeDashboardWithoutUsageLimits(email: "owner@example.com") + + let result = try CodexWebDashboardStrategy.makeAuthorizedDashboardResultForTesting( + dashboard: dashboard, + context: context, + routingTargetEmail: "route@example.com") + + #expect(result.usage.primary == nil) + #expect(result.usage.secondary == nil) + #expect(result.usage.updatedAt == dashboard.updatedAt) + #expect(result.usage.identity?.accountEmail == "owner@example.com") + #expect(result.usage.identity?.loginMethod == "pro") + #expect(result.credits?.remaining == 42) + #expect(result.dashboard == dashboard) + } + @Test func `web dashboard display only throws typed policy error`() throws { OpenAIDashboardCacheStore.clear() @@ -332,6 +365,21 @@ struct CodexWebDashboardStrategyAuthorityTests { updatedAt: Date(timeIntervalSince1970: 2000)) } + private func makeDashboardWithoutUsageLimits(email: String) -> OpenAIDashboardSnapshot { + OpenAIDashboardSnapshot( + signedInEmail: email, + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: nil, + creditsRemaining: 42, + accountPlan: "pro", + updatedAt: Date(timeIntervalSince1970: 2000)) + } + private func makeAuthHome(email: String?, accountId: String? = nil) throws -> URL { let homeURL = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, diff --git a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift index 3ae186a6..c5083bec 100644 --- a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift @@ -25,4 +25,68 @@ struct CopilotUsageFetcherTests { #expect(requests.count == 1) #expect(requests.first?.url?.host == "api.github.com") } + + @Test + func `fetch returns unavailable snapshot for business token billing placeholders`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "token gh-token") + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "business", + "token_based_billing": true, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "premium_interactions" + }, + "chat": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "chat" + } + } + } + """.utf8) + return (data, response) + } + let fetcher = CopilotUsageFetcher(token: "gh-token", transport: transport) + + let snapshot = try await fetcher.fetch() + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.identity?.loginMethod == "Business") + } + + @Test + func `makeRateWindow drops business token billing placeholder quota`() { + // entitlement=0/remaining=0/percent_remaining=100 must not become a "0% used" + // rate window for Copilot Business token-based billing accounts. (#1258) + let placeholder = CopilotUsageResponse.QuotaSnapshot( + entitlement: 0, + remaining: 0, + percentRemaining: 100, + quotaId: "premium_interactions") + #expect(CopilotUsageFetcher.makeRateWindow(from: placeholder) == nil) + } + + @Test + func `makeRateWindow keeps real quota window`() { + let real = CopilotUsageResponse.QuotaSnapshot( + entitlement: 500, + remaining: 125, + percentRemaining: 25, + quotaId: "premium_interactions") + let window = CopilotUsageFetcher.makeRateWindow(from: real) + #expect(window?.usedPercent == 75) + } } diff --git a/Tests/CodexBarTests/CopilotUsageModelsTests.swift b/Tests/CodexBarTests/CopilotUsageModelsTests.swift index e59d2ad9..48f99b98 100644 --- a/Tests/CodexBarTests/CopilotUsageModelsTests.swift +++ b/Tests/CodexBarTests/CopilotUsageModelsTests.swift @@ -437,6 +437,87 @@ struct CopilotUsageModelsTests { #expect(response.quotaSnapshots.chat == nil) } + @Test + func `treats business token billing zero entitlement quotas as unavailable`() throws { + // GitHub Copilot Business token-based billing reports every quota as + // entitlement=0, remaining=0, percent_remaining=100. That previously rendered as a + // misleading "0% used" (100 - 100). A zero-entitlement quota carries no usage signal, + // so the snapshots must drop out instead of showing as usage. (#1258) + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "business", + "token_based_billing": true, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "premium_interactions" + }, + "chat": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "chat" + }, + "completions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "completions" + } + } + } + """) + + #expect(response.tokenBasedBilling) + #expect(response.quotaSnapshots.premiumInteractions == nil) + #expect(response.quotaSnapshots.chat == nil) + } + + @Test + func `flags zero entitlement snapshot as placeholder`() { + let snapshot = CopilotUsageResponse.QuotaSnapshot( + entitlement: 0, + remaining: 0, + percentRemaining: 100, + quotaId: "chat") + #expect(snapshot.isPlaceholder) + } + + @Test + func `keeps fully consumed quota with positive entitlement`() { + // entitlement > 0 with remaining 0 is a real "100% used" window, not a placeholder. + let snapshot = CopilotUsageResponse.QuotaSnapshot( + entitlement: 500, + remaining: 0, + percentRemaining: 0, + quotaId: "premium_interactions") + #expect(!snapshot.isPlaceholder) + #expect(snapshot.usedPercent == 100) + } + + @Test + func `keeps percent only quota snapshots available`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "business", + "quota_snapshots": { + "chat": { + "percent_remaining": 40, + "quota_id": "chat" + } + } + } + """) + + #expect(response.quotaSnapshots.chat?.percentRemaining == 40) + #expect(response.quotaSnapshots.chat?.usedPercent == 60) + #expect(response.quotaSnapshots.chat?.isPlaceholder == false) + } + private static func decodeFixture(_ fixture: String) throws -> CopilotUsageResponse { try JSONDecoder().decode(CopilotUsageResponse.self, from: Data(fixture.utf8)) } diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift new file mode 100644 index 00000000..df7761a3 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -0,0 +1,286 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageFetcherCacheSnapshotTests { + @Test + func `cached codex token snapshot loads from existing cache without rescanning`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + + #expect(cached?.sessionTokens == 42) + #expect(cached?.last30DaysTokens == 42) + #expect(cached?.daily.map(\.date) == ["2026-04-08"]) + } + + @Test + func `cached codex token snapshot refuses expanded or managed scopes`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + let expanded = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 7, + scannerOptions: options) + let managed = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + codexHomePath: env.codexHomeRoot.path, + historyDays: 1, + scannerOptions: options) + + #expect(expanded == nil) + #expect(managed == nil) + } + + @Test + func `cached codex token snapshot refuses mismatched roots fingerprint`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.roots = [env.root.appendingPathComponent("other/sessions", isDirectory: true).path: 0] + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + + #expect(cached == nil) + } + + @Test + func `cached codex token snapshot merges cached pi sessions`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + try Self.writePiCodexSessionFile(env: env, day: day, tokens: 165) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + refreshPricingInBackground: false, + scannerOptions: options, + piScannerOptions: piOptions) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + + #expect(cached?.sessionTokens == 207) + #expect(cached?.last30DaysTokens == 207) + } + + @Test + func `cached codex token snapshot loads cached pi sessions without native codex cache`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writePiCodexSessionFile(env: env, day: day, tokens: 165) + + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + _ = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: piOptions) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot)) + + #expect(cached?.sessionTokens == 165) + #expect(cached?.last30DaysTokens == 165) + } + + @Test + func `cached codex token snapshot still loads pi sessions when native cache roots mismatch`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + try Self.writePiCodexSessionFile(env: env, day: day, tokens: 165) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + refreshPricingInBackground: false, + scannerOptions: options, + piScannerOptions: piOptions) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.roots = [env.root.appendingPathComponent("other/sessions", isDirectory: true).path: 0] + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + + #expect(cached?.sessionTokens == 165) + #expect(cached?.last30DaysTokens == 165) + } + + private static func writeCodexSessionFile( + homeRoot: URL, + env: CostUsageTestEnvironment, + day: Date, + filename: String, + tokens: Int) throws + { + let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) + let dir = homeRoot + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(String(format: "%04d", comps.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let model = "openai/gpt-5.4" + let url = dir.appendingPathComponent(filename, isDirectory: false) + try env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ], + ]).write(to: url, atomically: true, encoding: .utf8) + } + + private static func writePiCodexSessionFile( + env: CostUsageTestEnvironment, + day: Date, + tokens: Int) throws + { + _ = try env.writePiSessionFile( + relativePath: "nested/run-0/2026-04-08T10-00-00-000Z_test.jsonl", + contents: env.jsonl([ + [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": tokens, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": tokens, + ], + ], + ], + ])) + } +} diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 4d4ea170..a9a90d2a 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -360,6 +360,22 @@ struct CostUsagePricingTests { #expect(cost == expected) } + @Test + func `claude cost supports opus48`() throws { + // Point at a fresh, empty cache root so the models.dev lookup misses and this + // exercises the built-in fallback table specifically — not a local cache hit. + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-opus-4-8", + inputTokens: 10, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 5, + modelsDevCacheRoot: emptyCacheRoot) + let expected = (10.0 * 5e-6) + (5.0 * 2.5e-5) + #expect(cost == expected) + } + @Test func `claude cost returns nil for unknown models`() { let cost = CostUsagePricing.claudeCostUSD( diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift index 7600dfc0..5cacd77a 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift @@ -158,6 +158,54 @@ struct CostUsageScannerClaudeRegressionTests { #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) } + /// Regression for https://github.com/steipete/CodexBar/issues/1210: an Opus 4.8 row + /// priced to an empty cost because the built-in Claude pricing table had no + /// claude-opus-4-8 entry (used when the models.dev cache is missing/stale). + @Test + func `claude opus 4 8 issue row gets priced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 29) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/opus-48.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-opus-4-8", + "id": "msg_01NrvWoSMk2Eig6vkCgyRZqc", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 6, + "cache_creation_input_tokens": 1389, + "cache_read_input_tokens": 50352, + "output_tokens": 3922, + ], + ], + "requestId": "req_011CaLLcFQD712ZnCTxHFk71", + "type": "assistant", + "timestamp": "2026-05-29T07:51:34.428Z", + "sessionId": "39d4b923-8273-4c35-ad9c-e098395286f1", + ], + ])) + + let parsed = CostUsageScanner.parseClaudeFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + providerFilter: .all) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows[0].model == "claude-opus-4-8") + #expect(parsed.rows[0].input == 6) + #expect(parsed.rows[0].cacheCreate == 1389) + #expect(parsed.rows[0].cacheRead == 50352) + #expect(parsed.rows[0].output == 3922) + + let expected = 0.13193725 + #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) + } + @Test func `claude streaming keeps the last cumulative chunk`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index 2f7fadf5..336f3ae3 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -684,6 +684,32 @@ struct CostUsageScannerTests { #expect(delta.rows.first?.output == 6) } + @Test + func `codex fast parser does not trap on overflowing token integers`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso = env.isoString(for: day) + let hugeInteger = String(repeating: "9", count: 100) + let line = """ + {"type":"event_msg","timestamp":"\( + iso)","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":\( + hugeInteger),"cached_input_tokens":0,"output_tokens":5},"model":"openai/gpt-5.5"}}} + """ + let fileURL = try env.writeCodexSessionFile(day: day, filename: "overflow.jsonl", contents: line + "\n") + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let parsed = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed.count >= 3) + #expect(packed[0] == 0) + #expect(packed[1] == 0) + #expect(packed[2] == 5) + } + @Test func `claude incremental parsing reads appended lines only`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift index c1d8e2cf..5c01dd2d 100644 --- a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift +++ b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift @@ -88,7 +88,8 @@ struct FactoryStatusProbeFetchTests { homeDirectory: "/tmp/codexbar-empty-browser-home", cacheTTL: 0, fileExists: { _ in false }, - directoryContents: { _ in nil })) + directoryContents: { _ in nil }), + transport: FactoryStubTransport()) let snapshot = try await probe.fetch() @@ -205,7 +206,8 @@ struct FactoryStatusProbeFetchTests { homeDirectory: "/tmp/codexbar-empty-browser-home", cacheTTL: 0, fileExists: { _ in false }, - directoryContents: { _ in nil })) + directoryContents: { _ in nil }), + transport: FactoryStubTransport()) let snapshot = try await probe.fetch() @@ -788,3 +790,14 @@ final class FactoryStubURLProtocol: URLProtocol { override func stopLoading() {} } + +private struct FactoryStubTransport: ProviderHTTPTransport { + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + guard let handler = FactoryStubURLProtocol.handler else { + throw URLError(.badServerResponse) + } + FactoryStubURLProtocol.requests.append(request) + let (response, data) = try handler(request) + return (data, response) + } +} diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 0e415239..de474a86 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -28,6 +28,16 @@ struct KeychainCacheStoreTests { } } + @Test + func `background interaction keeps real keychain cache available for no UI reads writes and deletes`() { + KeychainAccessGate.withTaskOverrideForTesting(false) { + ProviderInteractionContext.$current.withValue(.background) { + #expect(KeychainCacheStore.canUseRealKeychainForTesting == true) + #expect(KeychainCacheStore.canEnumerateOrDeleteRealKeychainForTesting == true) + } + } + } + @Test func `stores and loads entry`() { KeychainCacheStore.setTestStoreForTesting(true) @@ -146,6 +156,12 @@ struct KeychainCacheStoreTests { } } + @Test + func `delete interaction not allowed is non fatal`() { + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + #expect(KeychainCacheStore.clearResultForKeychainDeleteStatus(errSecInteractionNotAllowed, key: key) == false) + } + @Test func `load failure override bypasses test store without affecting store or clear`() { KeychainCacheStore.setTestStoreForTesting(true) diff --git a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift index ce47ef1f..f86d0ee2 100644 --- a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift +++ b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift @@ -1,3 +1,4 @@ +import CoreGraphics import Foundation import Testing @testable import CodexBar @@ -63,6 +64,52 @@ struct MenuBarVisibilityWatcherTests { #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) } + @Test + func `window probe matches autosave name and reports display bounds`() { + let snapshots = MenuBarStatusItemWindowProbe.snapshots( + matching: ["codexbar-merged"], + windowInfo: [[ + kCGWindowName as String: "codexbar-merged", + kCGWindowOwnerName as String: "Control Center", + kCGWindowIsOnscreen as String: true, + kCGWindowBounds as String: [ + "X": 1680, + "Y": 0, + "Width": 70, + "Height": 24, + ], + ]], + displayBounds: [CGRect(x: 0, y: 0, width: 2056, height: 1329)]) + + #expect(snapshots.count == 1) + #expect(snapshots.first?.name == "codexbar-merged") + #expect(snapshots.first?.ownerName == "Control Center") + #expect(snapshots.first?.isOnscreen == true) + #expect(snapshots.first?.isWithinDisplayBounds == true) + } + + @Test + func `window probe detects offscreen status item by bounds`() { + let snapshots = MenuBarStatusItemWindowProbe.snapshots( + matching: ["codexbar-merged"], + windowInfo: [[ + kCGWindowName as String: "codexbar-merged", + kCGWindowOwnerName as String: "Control Center", + kCGWindowIsOnscreen as String: true, + kCGWindowBounds as String: [ + "X": 2023, + "Y": 0, + "Width": 71, + "Height": 24, + ], + ]], + displayBounds: [CGRect(x: 0, y: 0, width: 2056, height: 1329)]) + + #expect(snapshots.count == 1) + #expect(snapshots.first?.isOnscreen == true) + #expect(snapshots.first?.isWithinDisplayBounds == false) + } + @Test func `allows visible item attached to a detached screen`() { let snapshot = StatusItemVisibilitySnapshot( diff --git a/Tests/CodexBarTests/MenuCardAntigravityTests.swift b/Tests/CodexBarTests/MenuCardAntigravityTests.swift index 3f7037c0..f2947a61 100644 --- a/Tests/CodexBarTests/MenuCardAntigravityTests.swift +++ b/Tests/CodexBarTests/MenuCardAntigravityTests.swift @@ -143,7 +143,8 @@ struct MenuCardAntigravityTests { resetDescription: nil), ], accountEmail: nil, - accountPlan: "Pro") + accountPlan: "Pro", + source: .local) let snapshot = try antigravitySnapshot.toUsageSnapshot() let metadata = try #require(ProviderDefaults.metadata[.antigravity]) diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift index e7dde89f..e25283f5 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -17,6 +17,13 @@ struct OllamaUsageFetcherTests { #expect(!OllamaUsageFetcher.shouldAttachCookie(to: nil)) } + @Test + func `rejects non https ollama urls`() { + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://ollama.com/settings"))) + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://www.ollama.com"))) + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://app.ollama.com/path"))) + } + @Test func `manual mode without valid header throws no session cookie`() { do { diff --git a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift index e75da4dc..f7709fc1 100644 --- a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift @@ -235,6 +235,25 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(!OpenAIDashboardFetcher.isUsageRoute(nil)) } + @Test(arguments: [ + ("https://chatgpt.com/#usage", true, false, false, false), + ("https://chatgpt.com/", false, false, true, false), + ("https://chatgpt.com/", false, false, false, true) + ]) + func `usage route reload skips blocking states`( + href: String, + loginRequired: Bool, + workspacePicker: Bool, + cloudflareInterstitial: Bool, + expected: Bool) + { + #expect(OpenAIDashboardFetcher.shouldReloadUsageRoute( + href: href, + loginRequired: loginRequired, + workspacePicker: workspacePicker, + cloudflareInterstitial: cloudflareInterstitial) == expected) + } + @Test func `dashboard requests prefer English localization`() throws { let url = try #require(URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")) diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index 0ee8e0e7..37db0483 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -59,6 +59,22 @@ struct OpenAIDashboardNavigationDelegateTests { } } + @MainActor + @Test + func `explicit cancel completes with cancellation error`() { + var result: Result? + let delegate = NavigationDelegate { result = $0 } + + delegate.cancel() + + switch result { + case let .failure(error)?: + #expect(error is CancellationError) + default: + #expect(Bool(false)) + } + } + @MainActor @Test func `commit completes navigation successfully after grace period`() async { diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift index eead8bb8..e9a016db 100644 --- a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -8,7 +8,8 @@ struct OpenAIWebRefreshGateTests { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: true, - force: false)) + force: false, + refreshPhase: .regular)) #expect(shouldRun == false) } @@ -18,7 +19,8 @@ struct OpenAIWebRefreshGateTests { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: false, - force: false)) + force: false, + refreshPhase: .regular)) #expect(shouldRun == true) } @@ -28,7 +30,48 @@ struct OpenAIWebRefreshGateTests { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: true, - force: true)) + force: true, + refreshPhase: .regular)) + + #expect(shouldRun == true) + } + + @Test + func `Startup skips automatic OpenAI web refreshes`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: false, + force: false, + refreshPhase: .startup)) + + #expect(shouldRun == false) + } + + @Test + func `Startup connectivity retry remains startup only for OpenAI web refresh gate`() { + let providerPhase = UsageStore.refreshPhase( + hasCompletedInitialRefresh: true) + let openAIWebPhase = UsageStore.openAIWebRefreshPhase( + providerRefreshPhase: providerPhase, + startupConnectivityRetryAttempt: 1) + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: false, + force: false, + refreshPhase: openAIWebPhase)) + + #expect(providerPhase == .regular) + #expect(openAIWebPhase == .startup) + #expect(shouldRun == false) + } + + @Test + func `Manual startup refresh still forces OpenAI web refreshes`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: true, + force: true, + refreshPhase: .startup)) #expect(shouldRun == true) } @@ -110,4 +153,36 @@ struct OpenAIWebRefreshGateTests { #expect(shouldSkip == false) } + + @Test + func `Empty dashboard history retry is throttled after a recent attempt`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebEmptyHistoryRetry(.init( + force: false, + accountDidChange: false, + lastError: nil, + lastSnapshotAt: now.addingTimeInterval(-120), + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == true) + } + + @Test + func `Empty dashboard history retry runs once for a newer empty snapshot`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebEmptyHistoryRetry(.init( + force: false, + accountDidChange: false, + lastError: nil, + lastSnapshotAt: now.addingTimeInterval(-60), + lastAttemptAt: now.addingTimeInterval(-120), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == false) + } } diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 53bcbf11..6db1d3c3 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -1,6 +1,8 @@ import AppKit +import CodexBarCore import Foundation import Testing +@testable import CodexBar @MainActor struct ProviderIconResourcesTests { @@ -53,6 +55,19 @@ struct ProviderIconResourcesTests { #expect(groq != grok) } + @Test + func `provider brand icons are cached after first load`() throws { + ProviderBrandIcon.resetCacheForTesting() + defer { ProviderBrandIcon.resetCacheForTesting() } + + let first = try #require(ProviderBrandIcon.image(for: .codex)) + let second = try #require(ProviderBrandIcon.image(for: .codex)) + + #expect(first === second) + #expect(first.size == NSSize(width: 16, height: 16)) + #expect(first.isTemplate) + } + private static func repoRoot() throws -> URL { var dir = URL(filePath: #filePath).deletingLastPathComponent() for _ in 0..<12 { diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index b2629ece..7c01c3e2 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -27,6 +27,30 @@ struct ProvidersPaneCoverageTests { #expect(!copilotDescriptor.showsOrganizationField) } + @Test + func `provider search filters display names and raw ids`() { + let providers: [UsageProvider] = [.codex, .claude, .openrouter, .deepseek] + let names: [UsageProvider: String] = [ + .codex: "Codex", + .claude: "Claude", + .openrouter: "OpenRouter", + .deepseek: "DeepSeek", + ] + + #expect( + ProvidersPane.filteredProviders(providers, query: " ", displayName: { names[$0] ?? $0.rawValue }) + == providers) + #expect( + ProvidersPane.filteredProviders(providers, query: "router", displayName: { names[$0] ?? $0.rawValue }) + == [.openrouter]) + #expect( + ProvidersPane.filteredProviders(providers, query: "CLA", displayName: { names[$0] ?? $0.rawValue }) + == [.claude]) + #expect( + ProvidersPane.filteredProviders(providers, query: "deepseek", displayName: { _ in "API" }) + == [.deepseek]) + } + @Test func `open router menu bar metric picker shows only automatic and primary`() { Self.withEnglishLocalization { diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index fc064a9d..dfef3960 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -77,6 +77,115 @@ struct StatusItemAnimationSignatureTests { #expect(codexSignature?.contains("style=codex") == true) } + @Test + func `merged brand percent reapplies title when cached render is skipped`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-brand-percent-title-restore" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 23, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let displayText = try #require(controller.menuBarDisplayText(for: .codex, snapshot: snapshot)) + let expectedTitle = StatusItemController.buttonTitle(displayText, hasImage: true) + controller.applyIcon(phase: nil) + let button = try #require(controller.statusItem.button) + #expect(button.title == expectedTitle) + #expect(button.imagePosition == .imageLeft) + + button.title = "" + button.imagePosition = .imageOnly + + let skipped = controller.applyIcon(phase: nil) + + #expect(skipped) + #expect(button.title == expectedTitle) + #expect(button.imagePosition == .imageLeft) + } + + @Test + func `merged fallback provider follows enabled provider order`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-provider-order" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + settings.setProviderOrder([.synthetic, .codex]) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setSnapshotForTesting(snapshot, provider: .synthetic) + + controller.applyIcon(phase: nil) + + #expect(store.enabledProviders().prefix(2) == [.synthetic, .codex]) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=synthetic") == true) + } + @Test func `merged icon follows overview provider order when first overview provider is loading`() { let suite = "StatusItemAnimationSignatureTests-merged-overview-provider-order" diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index 86d247d9..8fce39ad 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -98,16 +98,16 @@ struct StatusItemControllerSplitLifecycleTests { } @Test - func `status items publish stable non persistent manager identity`() throws { + func `status items publish stable manager identity`() throws { let (_, controller) = try self.makeSplitController() defer { controller.releaseStatusItemsForTesting() } let codexButton = try #require(controller.statusItems[.codex]?.button) let claudeButton = try #require(controller.statusItems[.claude]?.button) - #expect(!controller.statusItem.autosaveName.hasPrefix("CodexBar.")) - #expect(controller.statusItems[.codex]?.autosaveName.hasPrefix("CodexBar.") == false) - #expect(controller.statusItems[.claude]?.autosaveName.hasPrefix("CodexBar.") == false) + #expect(controller.statusItem.autosaveName == "codexbar-merged") + #expect(controller.statusItems[.codex]?.autosaveName == "codexbar-codex") + #expect(controller.statusItems[.claude]?.autosaveName == "codexbar-claude") #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") #expect(codexButton.accessibilityIdentifier() == "CodexBar.StatusItem.codex") #expect(claudeButton.accessibilityIdentifier() == "CodexBar.StatusItem.claude") @@ -116,6 +116,218 @@ struct StatusItemControllerSplitLifecycleTests { #expect(claudeButton.accessibilityTitle() == "CodexBar") } + @Test + func `status item identity returns stable autosave names`() { + #expect(StatusItemController.StatusItemIdentity.merged.autosaveName == "codexbar-merged") + #expect(StatusItemController.StatusItemIdentity.provider(.codex).autosaveName == "codexbar-codex") + #expect(StatusItemController.StatusItemIdentity.provider(.claude).autosaveName == "codexbar-claude") + } + + @Test + func `status item placement preflight leaves fresh install placement unset`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-missing-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `status item placement preflight preserves missing new key when legacy item placement exists`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + legacyDefaultItemIndex: 0)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + } + + @Test + func `status item placement preflight clears suspicious matching legacy placement`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-high-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-0") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + legacyDefaultItemIndex: 0, + maximumPreferredPosition: 3000)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.object(forKey: "NSStatusItem Preferred Position Item-0") == nil) + } + + @Test + func `status item placement preflight preserves missing new key when mixed legacy placements exist`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-mixed-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-1") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + legacyDefaultItemIndex: 0)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 11298) + } + + @Test + func `status item placement preflight clears provider matching suspicious legacy placement`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-mixed-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-1") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1, + maximumPreferredPosition: 3000)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + #expect(defaults.object(forKey: "NSStatusItem Preferred Position Item-1") == nil) + } + + @Test + func `status item placement preflight leaves provider key unset when only merged legacy placement exists`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-single-legacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + } + + @Test + func `status item placement preflight preserves provider key with matching legacy placement`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-matching-legacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + defaults.set(58, forKey: "NSStatusItem Preferred Position Item-1") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 58) + } + + @Test + func `status item placement preflight clears suspicious high position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-high-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(11298, forKey: key) + + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + maximumPreferredPosition: 3000)) + + #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `status item placement preflight clears old forced zero position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-zero-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(0, forKey: key) + + #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `status item placement preflight clears malformed position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-malformed-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set("not-a-position", forKey: key) + + #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `status item placement preflight preserves reasonable position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-preserve-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(42, forKey: key) + + #expect(!MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + #expect(defaults.double(forKey: key) == 42) + } + + @Test + func `status item placement preflight preserves large display position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-preserve-large-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(2500, forKey: key) + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + maximumPreferredPosition: 2560)) + + #expect(defaults.double(forKey: key) == 2500) + } + @Test func `status item defaults repair removes stale hidden Control Center keys once`() throws { let suite = "StatusItemControllerSplitLifecycleTests-repair-\(UUID().uuidString)" @@ -164,7 +376,7 @@ struct StatusItemControllerSplitLifecycleTests { #expect(newCodexItem === oldCodexItem) #expect(newClaudeItem === oldClaudeItem) #expect(newCodexItem.button === oldCodexButton) - #expect(!newCodexItem.autosaveName.hasPrefix("CodexBar.")) + #expect(newCodexItem.autosaveName == "codexbar-codex") #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") } @@ -182,7 +394,7 @@ struct StatusItemControllerSplitLifecycleTests { #expect(controller.statusItem === oldMergedItem) #expect(controller.statusItem.button === oldMergedButton) - #expect(!controller.statusItem.autosaveName.hasPrefix("CodexBar.")) + #expect(controller.statusItem.autosaveName == "codexbar-merged") #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") } @@ -214,7 +426,7 @@ struct StatusItemControllerSplitLifecycleTests { let newCodexItem = try #require(controller.statusItems[.codex]) #expect(newCodexItem !== oldCodexItem) - #expect(!newCodexItem.autosaveName.hasPrefix("CodexBar.")) + #expect(newCodexItem.autosaveName == "codexbar-codex") #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") } @@ -232,7 +444,7 @@ struct StatusItemControllerSplitLifecycleTests { let mergedButton = try #require(controller.statusItem.button) #expect(mergedButton.image != nil) - #expect(!controller.statusItem.autosaveName.hasPrefix("CodexBar.")) + #expect(controller.statusItem.autosaveName == "codexbar-merged") #expect(mergedButton.accessibilityIdentifier() == "CodexBar.StatusItem") } } diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index 845c2ab6..a528c5c5 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -7,14 +7,11 @@ import Testing @Suite(.serialized) struct StatusMenuHostedSubmenuRefreshTests { @Test - func `open parent menu defers data rebuild until next open`() throws { + func `open parent menu defers data rebuild until parent tracking ends`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled - let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = true - StatusItemController.setMenuRefreshEnabledForTesting(false) defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering - StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let settings = Self.makeSettings() @@ -37,6 +34,7 @@ struct StatusMenuHostedSubmenuRefreshTests { preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = false let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -54,26 +52,180 @@ struct StatusMenuHostedSubmenuRefreshTests { #expect(submenu.minimumWidth >= StatusItemController.menuCardBaseWidth) #expect(submenu.items.first?.view == nil) - StatusItemController.setMenuRefreshEnabledForTesting(true) + controller.menuRefreshEnabledOverrideForTesting = true controller.menuWillOpen(submenu) let submenuKey = ObjectIdentifier(submenu) #expect(controller.openMenus[submenuKey] === submenu) #expect(submenu.items.first?.view != nil) let oldParentVersion = try #require(controller.menuVersions[parentKey]) - controller.menuContentVersion &+= 1 - controller.refreshOpenMenusIfNeeded() + controller.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true) + #expect(controller.menuVersions[parentKey] == oldParentVersion) + controller.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true) #expect(controller.menuVersions[parentKey] == oldParentVersion) controller.menuDidClose(submenu) #expect(controller.openMenus[submenuKey] == nil) + for _ in 0..<40 where controller.menuVersions[parentKey] != oldParentVersion { + await Task.yield() + } #expect(controller.menuVersions[parentKey] == oldParentVersion) + controller.menuDidClose(menu) - controller.menuWillOpen(menu) + for _ in 0..<40 where controller.menuVersions[parentKey] != controller.menuContentVersion { + await Task.yield() + } + if controller.menuVersions[parentKey] != controller.menuContentVersion { + controller.menuWillOpen(menu) + } + for _ in 0..<40 where controller.menuVersions[parentKey] != controller.menuContentVersion { + await Task.yield() + } #expect(controller.menuVersions[parentKey] == controller.menuContentVersion) } + @Test + func `open hosted submenu rebuilds from unavailable placeholder when data arrives`() async { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.costUsageEnabled = true + Self.enableOnlyClaude(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + let submenuKey = ObjectIdentifier(submenu) + #expect(controller.openMenus[submenuKey] === submenu) + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.items.first?.view == nil) + #expect(submenu.items.first?.title == "No data available") + + let openedVersion = controller.menuContentVersion + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) + controller.invalidateMenus(refreshOpenMenus: true) + + for _ in 0..<40 { + if controller.menuContentVersion != openedVersion, + submenu.items.first?.view != nil + { + break + } + await Task.yield() + } + + #expect(controller.menuContentVersion != openedVersion) + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.items.first?.view != nil) + #expect(submenu.items.first?.title != "No data available") + } + + @Test + func `open hydrated provider submenu preserves identity across refresh`() throws { + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + seed: Self.seedClaudeSnapshots) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.costHistoryChartID, + provider: .openai, + seed: Self.seedOpenAICostSnapshot) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + seed: Self.seedPlanUtilizationHistory) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.storageBreakdownID, + provider: .claude, + seed: Self.seedStorageFootprint) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.zaiHourlyUsageChartID, + provider: .zai, + seed: Self.seedZaiHourlyUsage) + } + + private func assertHostedSubmenuPreservesIdentity( + chartID: String, + provider: UsageProvider, + seed: (UsageStore) -> Void) throws + { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = provider + settings.costUsageEnabled = true + settings.providerStorageFootprintsEnabled = true + Self.enableOnly(settings, provider: provider) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + seed(store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: chartID, + provider: provider, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + + let hydratedItem = try #require(submenu.items.first) + #expect(hydratedItem.representedObject as? String == chartID) + #expect(hydratedItem.toolTip == provider.rawValue) + #expect(hydratedItem.view != nil) + #expect(hydratedItem.title != "No data available") + + controller.refreshHostedSubviewMenu(submenu) + + let refreshedItem = try #require(submenu.items.first) + #expect(refreshedItem.representedObject as? String == chartID) + #expect(refreshedItem.toolTip == provider.rawValue) + #expect(refreshedItem.view != nil) + #expect(refreshedItem.title != "No data available") + } + private static func makeSettings() -> SettingsStore { let suite = "StatusMenuHostedSubmenuRefreshTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -86,12 +238,14 @@ struct StatusMenuHostedSubmenuRefreshTests { } private static func enableOnlyClaude(_ settings: SettingsStore) { + self.enableOnly(settings, provider: .claude) + } + + private static func enableOnly(_ settings: SettingsStore, provider enabledProvider: UsageProvider) { let registry = ProviderRegistry.shared - if let codexMeta = registry.metadata[.codex] { - settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: false) - } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == enabledProvider) } } @@ -107,7 +261,96 @@ struct StatusMenuHostedSubmenuRefreshTests { accountOrganization: nil, loginMethod: "Team")) store._setSnapshotForTesting(snapshot, provider: .claude) - store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) + } + + private static func seedOpenAICostSnapshot(in store: UsageStore) { + let day = Date(timeIntervalSince1970: 1_700_000_000) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2025-12-23", + startTime: day, + endTime: day.addingTimeInterval(86400), + costUSD: 1.23, + requests: 12, + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 40, + totalTokens: 160, + lineItems: [], + models: []), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_086_400)) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + openAIAPIUsage: apiUsage, + updatedAt: Date(timeIntervalSince1970: 1_700_086_400), + identity: ProviderIdentitySnapshot( + providerID: .openai, + accountEmail: "openai@example.com", + accountOrganization: nil, + loginMethod: "API")) + store._setSnapshotForTesting(snapshot, provider: .openai) + } + + private static func seedPlanUtilizationHistory(in store: UsageStore) { + self.seedClaudeSnapshots(in: store) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + unscoped: [ + PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: 24, + resetsAt: Date(timeIntervalSince1970: 1_700_018_000)), + ]), + ]) + } + + private static func seedStorageFootprint(in store: UsageStore) { + let root = "/Users/test/.claude" + store.providerStorageFootprints[.claude] = ProviderStorageFootprint( + provider: .claude, + totalBytes: 1024, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [.init(path: "\(root)/projects", totalBytes: 1024)], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + } + + private static func seedZaiHourlyUsage(in store: UsageStore) { + let modelUsage = ZaiModelUsageData( + xTime: ["2026-05-26 00:00"], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [512]), + ]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + zaiUsage: ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: nil, + planName: "Pro", + modelUsage: modelUsage, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: "zai@example.com", + accountOrganization: nil, + loginMethod: "OAuth")) + store._setSnapshotForTesting(snapshot, provider: .zai) + } + + private static func makeTokenSnapshot() -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( sessionTokens: 123, sessionCostUSD: 0.12, last30DaysTokens: 123, @@ -122,6 +365,6 @@ struct StatusMenuHostedSubmenuRefreshTests { modelsUsed: nil, modelBreakdowns: nil), ], - updatedAt: Date()), provider: .claude) + updatedAt: Date()) } } diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index aeec4c1c..3d622dd3 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -1,9 +1,116 @@ +import AppKit import CodexBarCore import Foundation import Testing @testable import CodexBar extension StatusMenuTests { + @Test + func `opening fresh menu does not schedule deferred refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + var providerRefreshCount = 0 + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + providerRefreshCount += 1 + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(providerRefreshCount == 0) + #expect(!controller.deferredMenuInteractionRefreshPending) + + controller.menuDidClose(menu) + for _ in 0..<40 { + await Task.yield() + } + + #expect(providerRefreshCount == 0) + #expect(refreshInteractions.isEmpty) + } + + @Test + func `menu open with missing data defers automatic refresh until tracking ends`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + var providerRefreshCount = 0 + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + providerRefreshCount += 1 + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(providerRefreshCount == 0) + #expect(controller.deferredMenuInteractionRefreshPending) + + controller.menuDidClose(menu) + for _ in 0..<40 where providerRefreshCount == 0 { + await Task.yield() + } + + #expect(providerRefreshCount == 1) + #expect(refreshInteractions == [.background]) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + @Test func `store observation marks open menu stale without rebuilding during tracking`() async { self.disableMenuCardsForTesting() @@ -26,8 +133,7 @@ extension StatusMenuTests { controller.menuWillOpen(menu) let key = ObjectIdentifier(menu) controller.openMenus[key] = menu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true let openedVersion = controller.menuVersions[key] var rebuildCount = 0 @@ -63,12 +169,13 @@ extension StatusMenuTests { } @Test - func `explicit store actions refresh a visible open menu`() async { + func `closed attached menu is prepared before next open after invalidation`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual - settings.mergeIcons = false + settings.mergeIcons = true + self.enableOnlyCodex(settings) let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) let controller = StatusItemController( @@ -79,38 +186,88 @@ extension StatusMenuTests { preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true let menu = controller.makeMenu() - controller.menuWillOpen(menu) + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) let key = ObjectIdentifier(menu) - controller.openMenus[key] = menu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus() + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `closed attached menu preparation waits for store refresh to finish`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) let openedVersion = controller.menuVersions[key] - var rebuildCount = 0 - controller._test_openMenuRebuildObserver = { _ in - rebuildCount += 1 + + store.isRefreshing = true + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() } - defer { controller._test_openMenuRebuildObserver = nil } - controller.refreshOpenMenusAfterExplicitStoreAction() - for _ in 0..<20 where rebuildCount == 0 { + #expect(controller.menuVersions[key] == openedVersion) + + store.isRefreshing = false + controller.invalidateMenus() + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { await Task.yield() } - #expect(controller.menuContentVersion != openedVersion) - #expect(rebuildCount == 1) - #expect(controller.menuVersions[key] != openedVersion) + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) } @Test - func `repeated explicit store actions coalesce to one open menu rebuild`() async { + func `closed attached menu preparation waits for token refresh to finish`() async { + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual - settings.mergeIcons = false + settings.mergeIcons = true + self.enableOnlyCodex(settings) let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) let controller = StatusItemController( @@ -122,38 +279,80 @@ extension StatusMenuTests { statusBar: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true let menu = controller.makeMenu() - controller.menuWillOpen(menu) + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) let key = ObjectIdentifier(menu) - controller.openMenus[key] = menu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + let openedVersion = controller.menuVersions[key] - var rebuildCount = 0 - controller._test_openMenuRebuildObserver = { _ in - rebuildCount += 1 + store.tokenRefreshInFlight.insert(.codex) + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() } - defer { controller._test_openMenuRebuildObserver = nil } - controller.refreshOpenMenusAfterExplicitStoreAction() - controller.refreshOpenMenusAfterExplicitStoreAction() - controller.refreshOpenMenusAfterExplicitStoreAction() + #expect(controller.menuVersions[key] == openedVersion) - for _ in 0..<20 where rebuildCount == 0 { + store.tokenRefreshInFlight.remove(.codex) + controller.invalidateMenus() + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { await Task.yield() } - #expect(rebuildCount == 1) + #expect(controller.openMenus.isEmpty) #expect(controller.menuVersions[key] == controller.menuContentVersion) } @Test - func `plain open menu refresh preserves pending switcher hosted submenu cleanup`() async { + func `closed menu rebuild cleanup runs when weak menu disappears`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual - settings.mergeIcons = false + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + let key: ObjectIdentifier + do { + let menu = NSMenu() + key = ObjectIdentifier(menu) + controller.rebuildClosedMenuIfNeeded(menu) + #expect(controller.closedMenuRebuildTasks[key] != nil) + #expect(controller.closedMenuRebuildTokens[key] != nil) + } + + for _ in 0..<40 where controller.closedMenuRebuildTasks[key] != nil { + await Task.yield() + } + + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.closedMenuRebuildTokens[key] == nil) + } + + @Test + func `menu open keeps stale nonempty content while store refresh is active`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) let controller = StatusItemController( @@ -165,34 +364,1121 @@ extension StatusMenuTests { statusBar: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + let openedItemCount = menu.items.count + + store.isRefreshing = true + defer { store.isRefreshing = false } + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) controller.menuWillOpen(menu) - let menuKey = ObjectIdentifier(menu) - controller.openMenus[menuKey] = menu + defer { controller.menuDidClose(menu) } - let submenu = controller.makeHostedSubviewPlaceholderMenu( - chartID: StatusItemController.usageBreakdownChartID, - provider: .codex) - let submenuKey = ObjectIdentifier(submenu) - controller.openMenus[submenuKey] = submenu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.menuContentVersion != openedVersion) + #expect(menu.items.count == openedItemCount) + #expect(controller.openMenus[key] === menu) + } + + @Test + func `menu open rebuilds stale content after privacy setting changes during refresh`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + store.isRefreshing = true + defer { store.isRefreshing = false } + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + settings.hidePersonalInfo = true + controller.invalidateMenus() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] != openedVersion) + } + + @Test + func `menu open keeps stale nonempty content while token refresh is active`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + let openedItemCount = menu.items.count + + store.tokenRefreshInFlight.insert(.codex) + defer { store.tokenRefreshInFlight.remove(.codex) } + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.menuContentVersion != openedVersion) + #expect(menu.items.count == openedItemCount) + #expect(controller.openMenus[key] === menu) + } + + @Test + func `explicit store actions refresh a visible open menu`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + let openedVersion = controller.menuVersions[key] var rebuildCount = 0 controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } defer { controller._test_openMenuRebuildObserver = nil } - controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) - controller.refreshOpenMenuIfStillVisible(menu, provider: .codex) - + controller.refreshOpenMenusAfterExplicitStoreAction() for _ in 0..<20 where rebuildCount == 0 { await Task.yield() } - #expect(controller.openMenus[submenuKey] == nil) + #expect(controller.menuContentVersion != openedVersion) #expect(rebuildCount == 1) - #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + #expect(controller.menuVersions[key] != openedVersion) + } + + @Test + func `repeated explicit store actions coalesce to one open menu rebuild`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAfterExplicitStoreAction() + controller.refreshOpenMenusAfterExplicitStoreAction() + controller.refreshOpenMenusAfterExplicitStoreAction() + + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `explicit refresh rebuilds stale parent after hosted submenu closes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageBreakdownChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = controller.menuVersions[menuKey] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAfterExplicitStoreAction() + for _ in 0..<20 where controller.menuContentVersion == openedVersion { + await Task.yield() + } + #expect(controller.menuVersions[menuKey] == openedVersion) + + controller.menuDidClose(submenu) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 1) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + } + + @Test + func `plain open menu refresh preserves pending switcher hosted submenu cleanup`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageBreakdownChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + controller.menuRefreshEnabledOverrideForTesting = true + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + controller.refreshOpenMenuIfStillVisible(menu, provider: .codex) + + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 1) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + } + + @Test + func `codex parent menu open defers stale OpenAI web refresh until tracking ends`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + var refreshInteractions: [ProviderInteraction] = [] + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + refreshInteractions.append(ProviderInteractionContext.current) + return try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(await blocker.startedCount() == 0) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + controller.menuDidClose(menu) + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + #expect(refreshInteractions == [.background]) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `programmatic parent menu close schedules deferred OpenAI web refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 0, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + controller.forgetClosedMenu(menu) + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `deferred OpenAI web refresh retries after active store refresh completes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + store.isRefreshing = true + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + controller.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open") + controller.scheduleDeferredMenuInteractionRefreshIfNeeded() + + try? await Task.sleep(for: .milliseconds(50)) + #expect(await blocker.startedCount() == 0) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + store.isRefreshing = false + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `deferred OpenAI web refresh waits for deferred store refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + let providerBlocker = BlockingStatusMenuProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await providerBlocker.awaitRelease() + } + defer { store._test_providerRefreshOverride = nil } + let dashboardBlocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await dashboardBlocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + await providerBlocker.waitUntilStarted() + #expect(await dashboardBlocker.startedCount() == 0) + + await providerBlocker.resumeNext() + await dashboardBlocker.waitUntilStarted(count: 1) + #expect(await dashboardBlocker.startedCount() == 1) + + await dashboardBlocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `reopened menu keeps dashboard refresh deferred after store refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + let providerBlocker = BlockingStatusMenuProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await providerBlocker.awaitRelease() + } + defer { store._test_providerRefreshOverride = nil } + let dashboardBlocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await dashboardBlocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + await providerBlocker.waitUntilStarted() + + let reopenedMenu = controller.makeMenu() + controller.menuWillOpen(reopenedMenu) + await providerBlocker.resumeNext() + try? await Task.sleep(for: .milliseconds(50)) + #expect(await dashboardBlocker.startedCount() == 0) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + controller.menuDidClose(reopenedMenu) + await dashboardBlocker.waitUntilStarted(count: 1) + #expect(await dashboardBlocker.startedCount() == 1) + + await dashboardBlocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `codex parent menu close refreshes recent dashboard cache with no chart history`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = self.makeOpenAIDashboard(dailyBreakdown: [], updatedAt: Date()) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(await blocker.startedCount() == 0) + + controller.menuDidClose(menu) + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: Date()))) + } + + @Test + func `codex parent menu open throttles recent empty dashboard retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let now = Date() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = self.makeOpenAIDashboard(dailyBreakdown: [], updatedAt: now.addingTimeInterval(-120)) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + store.lastOpenAIDashboardAttemptAt = now.addingTimeInterval(-60) + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + try? await Task.sleep(for: .milliseconds(150)) + #expect(await blocker.startedCount() == 0) + } + + @Test + func `credits history arriving after open rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.showOptionalCreditsAndExtraUsage = true + self.enableOnlyCodex(settings) + + let now = Date() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: true) + store.credits = CreditsSnapshot(remaining: 100, events: [], updatedAt: now) + store.openAIDashboard = self.makeOpenAIDashboard(dailyBreakdown: [], updatedAt: now) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + #expect(self.menuItem(in: menu, id: "menuCardCredits") == nil) + + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: now.addingTimeInterval(10)) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + + let creditsItem = try #require(self.menuItem(in: menu, id: "menuCardCredits")) + #expect( + creditsItem.submenu?.items.first?.representedObject as? String == + StatusItemController.creditsHistoryChartID) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `fresh dashboard history with same day count rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.showOptionalCreditsAndExtraUsage = true + self.enableOnlyCodex(settings) + + let now = Date(timeIntervalSince1970: 100) + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: true) + store.credits = CreditsSnapshot(remaining: 100, events: [], updatedAt: now) + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: now) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + _ = try #require(self.menuItem(in: menu, id: "menuCardCredits")) + + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 99), + ], + updatedAt: now.addingTimeInterval(10)) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + + let creditsItem = try #require(self.menuItem(in: menu, id: "menuCardCredits")) + #expect(creditsItem.submenu?.items.first?.representedObject as? String == StatusItemController + .creditsHistoryChartID) + } + + @Test + func `token cost history arriving after open rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + #expect(self.menuItem(in: menu, id: "menuCardCost") == nil) + + store._setTokenSnapshotForTesting(self.makeCodexTokenCostSnapshot(), provider: .codex) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + + let costItem = try #require(self.menuItem(in: menu, id: "menuCardCost")) + #expect(costItem.submenu?.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `fresh token cost history with same day count rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting( + self.makeCodexTokenCostSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 456, + last30DaysCostUSD: 1.23, + updatedAt: Date(timeIntervalSince1970: 100)), + provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + _ = try #require(self.menuItem(in: menu, id: "menuCardCost")) + + store._setTokenSnapshotForTesting( + self.makeCodexTokenCostSnapshot( + sessionTokens: 999, + sessionCostUSD: 0.99, + last30DaysTokens: 888, + last30DaysCostUSD: 8.88, + updatedAt: Date(timeIntervalSince1970: 200)), + provider: .codex) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + + let costItem = try #require(self.menuItem(in: menu, id: "menuCardCost")) + #expect(costItem.submenu?.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + } + + @Test + func `plan utilization history arriving after open rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + let usageHistoryItem = try #require(self.menuItem(in: menu, id: "usageHistorySubmenu")) + #expect(usageHistoryItem.submenu?.items.first?.representedObject as? String == StatusItemController + .usageHistoryChartID) + let openedRevision = store.planUtilizationHistoryRevision + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: self.makeCodexPlanUtilizationSnapshot(), + now: Date()) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(store.planUtilizationHistoryRevision > openedRevision) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + } + + @Test + func `dashboard attachment authorization arriving after open rebuilds parent menu after close`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let now = Date() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: now) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + #expect(store.openAIDashboardAttachmentRevision == 0) + + store.openAIDashboardAttachmentAuthorized = true + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(store.openAIDashboardAttachmentRevision == 1) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + private func menuItem(in menu: NSMenu, id: String) -> NSMenuItem? { + menu.items.first { ($0.representedObject as? String) == id } + } + + private func waitUntilMenuVersionChanges( + _ controller: StatusItemController, + from version: Int?) async + { + for _ in 0..<20 where controller.menuContentVersion == version { + await Task.yield() + } + } + + private func waitUntilOpenMenuStaysStale( + _ controller: StatusItemController, + key: ObjectIdentifier, + after version: Int?) async + { + for _ in 0..<40 { + guard controller.menuContentVersion != version else { + await Task.yield() + continue + } + guard controller.menuVersions[key] == version else { + await Task.yield() + continue + } + return + } + } + + private func closeMenuAndWaitUntilFresh( + _ controller: StatusItemController, + menu: NSMenu, + key: ObjectIdentifier) async + { + controller.menuDidClose(menu) + for _ in 0..<40 where controller.menuVersions[key] != controller.menuContentVersion { + await Task.yield() + } + if controller.menuVersions[key] != controller.menuContentVersion { + controller.menuWillOpen(menu) + } + for _ in 0..<40 where controller.menuVersions[key] != controller.menuContentVersion { + await Task.yield() + } + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + private func makeOpenAIDashboard( + dailyBreakdown: [OpenAIDashboardDailyBreakdown], + updatedAt: Date) -> OpenAIDashboardSnapshot + { + OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: dailyBreakdown, + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: updatedAt) + } + + private func makeCodexTokenCostSnapshot( + sessionTokens: Int = 123, + sessionCostUSD: Double = 0.12, + last30DaysTokens: Int = 456, + last30DaysCostUSD: Double = 1.23, + updatedAt: Date = Date()) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: sessionTokens, + sessionCostUSD: sessionCostUSD, + last30DaysTokens: last30DaysTokens, + last30DaysCostUSD: last30DaysCostUSD, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-05-24", + inputTokens: nil, + outputTokens: nil, + totalTokens: sessionTokens, + costUSD: last30DaysCostUSD, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: updatedAt) + } + + private func makeCodexPlanUtilizationSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 35, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(1800), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 42, + windowMinutes: 10080, + resetsAt: Date().addingTimeInterval(86400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")) + } +} + +private actor BlockingStatusMenuProviderRefresh { + private var continuations: [CheckedContinuation] = [] + private var startWaiters: [CheckedContinuation] = [] + private var started = 0 + + func awaitRelease() async { + self.started += 1 + self.resumeStartWaiters() + await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func waitUntilStarted() async { + if self.started > 0 { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(continuation) + } + } + + func resumeNext() { + guard !self.continuations.isEmpty else { return } + self.continuations.removeFirst().resume() + } + + private func resumeStartWaiters() { + let waiters = self.startWaiters + self.startWaiters = [] + for waiter in waiters { + waiter.resume() + } } } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index cd4e5e91..c29d770e 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -22,11 +22,13 @@ struct StatusMenuTests { 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.providerDetectionCompleted = true + return settings } func makeCodexStore(settings: SettingsStore, dashboardAuthorized: Bool) -> UsageStore { @@ -85,7 +87,6 @@ struct StatusMenuTests { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false - settings.providerDetectionCompleted = true settings.alibabaCodingPlanAPIRegion = .chinaMainland let fetcher = UsageFetcher() @@ -820,7 +821,6 @@ extension StatusMenuTests { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false - settings.providerDetectionCompleted = true let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -860,7 +860,6 @@ extension StatusMenuTests { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false - settings.providerDetectionCompleted = true let registry = ProviderRegistry.shared try settings.setProviderEnabled(provider: .codex, metadata: #require(registry.metadata[.codex]), enabled: true) @@ -884,8 +883,8 @@ extension StatusMenuTests { statusBar: self.makeStatusBarForTesting()) let codexItem = try #require(controller.statusItems[.codex]) - #expect(!controller.statusItem.autosaveName.hasPrefix("codexbar-")) - #expect(!codexItem.autosaveName.hasPrefix("codexbar-")) + #expect(controller.statusItem.autosaveName == "codexbar-merged") + #expect(codexItem.autosaveName == "codexbar-codex") try settings.setProviderEnabled( provider: .gemini, @@ -894,8 +893,8 @@ extension StatusMenuTests { controller.handleProviderConfigChange(reason: "test") #expect(controller.statusItems[.codex] === codexItem) - #expect(controller.statusItems[.codex]?.autosaveName.hasPrefix("codexbar-") == false) - #expect(controller.statusItems[.gemini]?.autosaveName.hasPrefix("codexbar-") == false) + #expect(controller.statusItems[.codex]?.autosaveName == "codexbar-codex") + #expect(controller.statusItems[.gemini]?.autosaveName == "codexbar-gemini") } @Test diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index d3904069..e2c9ee79 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -4,6 +4,8 @@ import Testing @Suite(.serialized) struct TTYCommandRunnerEnvTests { + private static let harnessPTYTimeout: TimeInterval = 10 + private final class CallbackCounter: @unchecked Sendable { private let lock = NSLock() private var count = 0 @@ -202,7 +204,10 @@ struct TTYCommandRunnerEnvTests { try fm.createDirectory(at: dir, withIntermediateDirectories: true) let runner = TTYCommandRunner() - let result = try runner.run(binary: "/bin/pwd", send: "", options: .init(timeout: 3, workingDirectory: dir)) + let result = try runner.run( + binary: "/bin/pwd", + send: "", + options: .init(timeout: Self.harnessPTYTimeout, workingDirectory: dir)) let clean = result.text.replacingOccurrences(of: "\r", with: "") #expect(clean.contains(dir.path)) } @@ -214,7 +219,7 @@ struct TTYCommandRunnerEnvTests { let result = try runner.run( binary: fakeClaude.path, send: "", - options: .init(timeout: 3, stopOnSubstrings: ["deep-link-enabled"])) + options: .init(timeout: Self.harnessPTYTimeout, stopOnSubstrings: ["deep-link-enabled"])) let clean = result.text.replacingOccurrences(of: "\r", with: "") #expect(clean.contains("deep-link-enabled")) @@ -228,7 +233,7 @@ struct TTYCommandRunnerEnvTests { binary: fakeClaude.path, send: "", options: .init( - timeout: 3, + timeout: Self.harnessPTYTimeout, stopOnSubstrings: ["deep-link-disabled"], useClaudeProbeWorkingDirectory: true)) let clean = result.text.replacingOccurrences(of: "\r", with: "") @@ -247,7 +252,7 @@ struct TTYCommandRunnerEnvTests { binary: fakeClaude.path, send: "", options: .init( - timeout: 3, + timeout: Self.harnessPTYTimeout, baseEnvironment: env, stopOnSubstrings: ["deep-link-disabled"], useClaudeProbeWorkingDirectory: true)) diff --git a/Tests/CodexBarTests/TTYIntegrationTests.swift b/Tests/CodexBarTests/TTYIntegrationTests.swift index dca1536f..a17e3c3f 100644 --- a/Tests/CodexBarTests/TTYIntegrationTests.swift +++ b/Tests/CodexBarTests/TTYIntegrationTests.swift @@ -65,7 +65,7 @@ struct TTYIntegrationTests { defer { Task { await ClaudeCLISession.shared.reset() } } let snapshot = try await ClaudeCLISession.withIsolatedSessionForTesting { - try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 8).fetch() + try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 10).fetch() } #expect(snapshot.sessionPercentLeft == 93) @@ -101,7 +101,7 @@ struct TTYIntegrationTests { *"/usage"*) printf '%s\\n' 'Settings Status Config Usage' printf '%s\\n' 'Current session' - sleep 4 + sleep 2 printf '%s\\n' '93% left' printf '%s\\n' 'Current week (all models)' printf '%s\\n' '79% left' diff --git a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift new file mode 100644 index 00000000..c51e1fd2 --- /dev/null +++ b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift @@ -0,0 +1,43 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +struct UsageMenuCardLayoutTests { + @Test + func `header only menu card keeps comfortable padding`() { + let model = UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "steipete@gmail.com", + subtitleText: "Not fetched yet", + subtitleStyle: .info, + planText: "Pro 20x", + metrics: [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: nil, + creditsRemaining: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: nil, + placeholder: nil, + progressColor: .blue) + let width: CGFloat = 296 + + let headerSize = NSHostingController(rootView: UsageMenuCardHeaderSectionView( + model: model, + showDivider: false, + width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + let cardSize = NSHostingController(rootView: UsageMenuCardView(model: model, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + + #expect(headerSize.height >= 46) + #expect(cardSize.height >= 46) + } +} diff --git a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift new file mode 100644 index 00000000..5b0fb659 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift @@ -0,0 +1,166 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +@Suite(.serialized) +struct UsageStoreCachedTokenHydrationTests { + @Test + func `cached codex token hydration populates startup token snapshot`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + let settings = Self.makeCodexOnlySettings(historyDays: 1) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + costUsageFetcher: CostUsageFetcher(scannerOptions: options), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + store.hydrateCachedTokenSnapshots(now: day) + + for _ in 0..<100 where store.tokenSnapshot(for: .codex) == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 42) + #expect(store.tokenSnapshot(for: .codex)?.daily.map(\.date) == ["2026-04-08"]) + #expect(store.tokenError(for: .codex) == nil) + } + + @Test + func `cached codex token hydration skips managed codex homes`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + let settings = Self.makeCodexOnlySettings(historyDays: 1) + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: env.codexHomeRoot.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + costUsageFetcher: CostUsageFetcher(scannerOptions: options), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + store.hydrateCachedTokenSnapshots(now: day) + + for _ in 0..<20 { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(store.tokenSnapshot(for: .codex) == nil) + } + + private static func makeCodexOnlySettings(historyDays: Int) -> SettingsStore { + let suite = "UsageStoreCachedTokenHydrationTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.costUsageHistoryDays = historyDays + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + settings.providerDetectionCompleted = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + return settings + } + + private static func writeCodexSessionFile( + homeRoot: URL, + env: CostUsageTestEnvironment, + day: Date, + filename: String, + tokens: Int) throws + { + let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) + let dir = homeRoot + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(String(format: "%04d", comps.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let model = "openai/gpt-5.4" + let url = dir.appendingPathComponent(filename, isDirectory: false) + try env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ], + ]).write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index f771e0bc..d17aa924 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -471,6 +471,90 @@ struct UsageStoreCoverageTests { NSError(domain: NSCocoaErrorDomain, code: 0))) } + @Test + func `startup status network failure schedules bounded retry`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-startup-status-retry") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_providerStatusFetchOverride = { _ in + throw URLError(.notConnectedToInternet) + } + defer { store._test_providerStatusFetchOverride = nil } + + var scheduled: [(attempt: Int, delay: TimeInterval)] = [] + store._test_startupConnectivityRetryScheduled = { attempt, delay in + scheduled.append((attempt, delay)) + } + defer { store._test_startupConnectivityRetryScheduled = nil } + + await store.refresh() + defer { + store.startupConnectivityRetryTask?.cancel() + store.startupConnectivityRetryTask = nil + } + + #expect(scheduled.map(\.attempt) == [1]) + #expect(scheduled.map(\.delay) == [15]) + #expect(store.statuses[.codex]?.indicator == .unknown) + #expect(store.statuses[.codex]?.description?.isEmpty == false) + } + + @Test + func `startup connectivity retry refreshes status and clears retry task after recovery`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-startup-status-recovery") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + + var statusAttempts = 0 + store._test_providerStatusFetchOverride = { _ in + statusAttempts += 1 + if statusAttempts == 1 { + throw URLError(.cannotFindHost) + } + return ProviderStatus(indicator: .none, description: "Operational", updatedAt: Date()) + } + defer { store._test_providerStatusFetchOverride = nil } + + let sleepGate = StartupConnectivityRetrySleepGate() + store._test_startupConnectivityRetrySleepOverride = { delay in + try await sleepGate.sleep(delay) + } + defer { store._test_startupConnectivityRetrySleepOverride = nil } + + await store.refresh() + await sleepGate.waitUntilSleeping() + let retryTask = try #require(store.startupConnectivityRetryTask) + + await sleepGate.resume() + await retryTask.value + + #expect(statusAttempts == 2) + #expect(store.statuses[.codex]?.indicator == ProviderStatusIndicator.none) + #expect(store.statuses[.codex]?.description == "Operational") + #expect(store.startupConnectivityRetryTask == nil) + } + + @Test + func `startup connectivity retry classification is bounded and excludes cancellation`() { + #expect(UsageStore.startupConnectivityRetryDelay(forAttempt: 1) == 15) + #expect(UsageStore.startupConnectivityRetryDelay(forAttempt: 4) == 300) + #expect(UsageStore.startupConnectivityRetryDelay(forAttempt: 5) == nil) + #expect(UsageStore.isStartupConnectivityRetryableError(URLError(.timedOut))) + #expect(UsageStore.isStartupConnectivityRetryableError(URLError(.notConnectedToInternet))) + #expect(!UsageStore.isStartupConnectivityRetryableError(URLError(.cancelled))) + #expect(!UsageStore.isStartupConnectivityRetryableError(CancellationError())) + } + private static func makeSettingsStore( suite: String, zaiTokenStore: any ZaiTokenStoring = NoopZaiTokenStore(), @@ -510,6 +594,49 @@ struct UsageStoreCoverageTests { settings: settings, environmentBase: [:]) } + + private static func enableOnly(_ enabledProvider: UsageProvider, settings: SettingsStore) throws { + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: provider == enabledProvider) + } + } +} + +private actor StartupConnectivityRetrySleepGate { + private var continuation: CheckedContinuation? + private var waiters: [CheckedContinuation] = [] + + func sleep(_ delay: TimeInterval) async throws { + #expect(delay == 15) + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + self.resumeWaiters() + } + } + + func waitUntilSleeping() async { + if self.continuation != nil { return } + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } + + private func resumeWaiters() { + let waiters = self.waiters + self.waiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } } private final class InMemoryZaiTokenStore: ZaiTokenStoring, @unchecked Sendable { diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj index 83a8c7f5..195b28ba 100644 --- a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj @@ -20,9 +20,9 @@ 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetBundle.swift; sourceTree = ""; }; 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetProvider.swift; sourceTree = ""; }; 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetViews.swift; sourceTree = ""; }; - 9FA0A78FB7CA1D877E7BA54B /* codexbar */ = {isa = PBXFileReference; lastKnownFileType = folder; name = codexbar; path = ..; sourceTree = SOURCE_ROOT; }; E430B27E4F28973A5E77EA3F /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CodexBarWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + EFBE36CB6481E7133E2A5CF3 /* CodexBar */ = {isa = PBXFileReference; lastKnownFileType = folder; name = CodexBar; path = ..; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -42,7 +42,7 @@ 4FAD1E2FCD6C4AC65D308ABC /* Packages */ = { isa = PBXGroup; children = ( - 9FA0A78FB7CA1D877E7BA54B /* codexbar */, + EFBE36CB6481E7133E2A5CF3 /* CodexBar */, ); name = Packages; sourceTree = ""; diff --git a/bin/install-codexbar-cli.sh b/bin/install-codexbar-cli.sh index 50fb9533..742a974e 100755 --- a/bin/install-codexbar-cli.sh +++ b/bin/install-codexbar-cli.sh @@ -10,23 +10,20 @@ if [[ ! -x "$HELPER" ]]; then exit 1 fi -install_script=$(mktemp) -cat > "$install_script" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -HELPER="__HELPER__" -TARGETS=("/usr/local/bin/codexbar" "/opt/homebrew/bin/codexbar") - -for t in "${TARGETS[@]}"; do - mkdir -p "$(dirname "$t")" - ln -sf "$HELPER" "$t" - echo "Linked $t -> $HELPER" -done -EOF - -perl -pi -e "s#__HELPER__#$HELPER#g" "$install_script" +osascript - "$HELPER" <<'APPLESCRIPT' +on run argv + set helperPath to item 1 of argv + set installCommand to "set -euo pipefail" & linefeed & ¬ + "HELPER=" & quoted form of helperPath & linefeed & ¬ + "TARGETS=(\"/usr/local/bin/codexbar\" \"/opt/homebrew/bin/codexbar\")" & linefeed & ¬ + "for t in \"${TARGETS[@]}\"; do" & linefeed & ¬ + " mkdir -p \"$(dirname \"$t\")\"" & linefeed & ¬ + " ln -sf \"$HELPER\" \"$t\"" & linefeed & ¬ + " echo \"Linked $t -> $HELPER\"" & linefeed & ¬ + "done" -osascript -e "do shell script \"bash '$install_script'\" with administrator privileges" -rm -f "$install_script" + do shell script "bash -c " & quoted form of installCommand with administrator privileges +end run +APPLESCRIPT echo "CodexBar CLI installed. Try: codexbar usage" diff --git a/docs/cli.md b/docs/cli.md index c9a01015..9bdb386b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -47,6 +47,7 @@ See `docs/configuration.md` for the schema. - `codexbar serve` starts a foreground localhost-only HTTP server for usage and cost JSON. - `--port ` defaults to `8080`. - `--refresh-interval ` defaults to `60` and controls the in-memory response cache TTL. + - `--request-timeout ` defaults to `30` and bounds each request before returning `504 Gateway Timeout`; use `0` to keep waiting indefinitely. - v1 binds to `127.0.0.1` only and rejects non-loopback `Host` headers. It does not expose remote bind, auth, CORS, TLS, or daemon mode. - Endpoints: `GET /health`, `GET /usage`, `GET /usage?provider=`, `GET /cost`, `GET /cost?provider=`. - Codex usage responses include every visible Codex account, matching the menu bar switcher. @@ -120,6 +121,7 @@ codexbar cost # local cost usage (default 30-day window + to codexbar cost --days 90 # choose a 1...365 day cost window codexbar cost --provider claude --format json --pretty codexbar serve --port 8080 # localhost HTTP JSON server +codexbar serve --request-timeout 0 # disable serve request deadlines COPILOT_API_TOKEN=... codexbar --provider copilot --format json --pretty codexbar --status # include status page indicator/description codexbar --provider codex --source oauth --format json --pretty diff --git a/version.env b/version.env index f6c1a278..8786f886 100644 --- a/version.env +++ b/version.env @@ -1,6 +1,6 @@ -MARKETING_VERSION=0.31.0.2 -BUILD_NUMBER=73.2 -MOBILE_VERSION=1.10.0 +MARKETING_VERSION=0.32.4.1 +BUILD_NUMBER=79.1 +MOBILE_VERSION=1.11.0 # Last upstream tag confirmed shipped to users. Lags MARKETING_VERSION # until the corresponding release reaches end users (Sparkle / App Store). # Bump this after the merged version is actually live, not at merge time.